From de61f870920ac187d9b3b4004febf8c341ee2250 Mon Sep 17 00:00:00 2001 From: shinchris Date: Fri, 16 Sep 2016 15:44:08 -0700 Subject: [PATCH 01/32] fixed publish mode enums and typos in samples --- samples/explore_datasource.py | 4 ++-- samples/explore_workbook.py | 4 ++-- samples/move_workbook_projects.py | 4 ++-- samples/move_workbook_sites.py | 6 +++--- samples/publish_workbook.py | 6 +++--- samples/set_http_options.py | 5 +---- 6 files changed, 13 insertions(+), 16 deletions(-) diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index 434cadd3b..601e0f2b5 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -1,5 +1,5 @@ #### -# This script demonstrates how to use the Tableau Server API +# This script demonstrates how to use the Tableau Server Client # to interact with datasources. It explores the different # functions that the Server API supports on datasources. # @@ -43,7 +43,7 @@ if args.publish: if default_project is not None: new_datasource = TSC.DatasourceItem(default_project.id) - new_datasource = server.datasources.publish(new_datasource, args.publish, server.PublishMode.Overwrite) + new_datasource = server.datasources.publish(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.") diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index 93897388e..35bd4222c 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -1,5 +1,5 @@ #### -# This script demonstrates how to use the Tableau Server API +# 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. # @@ -45,7 +45,7 @@ if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) - new_workbook = server.workbooks.publish(new_workbook, args.publish, server.PublishMode.Overwrite) + new_workbook = server.workbooks.publish(new_workbook, args.publish, TSC.Server.PublishMode.Overwrite) print("Workbook published. ID: {}".format(new_workbook.id)) else: print('Publish failed. Could not find the default project.') diff --git a/samples/move_workbook_projects.py b/samples/move_workbook_projects.py index 5e7835e72..3b553efb7 100644 --- a/samples/move_workbook_projects.py +++ b/samples/move_workbook_projects.py @@ -1,10 +1,10 @@ #### -# This script demonstrates how to use the Tableau Server API +# This script demonstrates how to use the Tableau Server Client # to move a workbook from one project to another. It will find # 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.9 or later. +# To run the script, you must have installed Python 2.7.X or 3.3 and later. #### import tableauserverclient as TSC diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index 13103f82e..68904f3cf 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -1,10 +1,10 @@ #### -# This script demonstrates how to use the Tableau Server API +# This script demonstrates how to use the Tableau Server Client # to move a workbook from one site to another. It will find # 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.9 or later. +# To run the script, you must have installed Python 2.7.X or 3.3 and later. #### import tableauserverclient as TSC @@ -74,7 +74,7 @@ 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=dest_server.PublishMode.Overwrite) + 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." diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 266cd1b2e..6a720fc35 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -1,5 +1,5 @@ #### -# This script demonstrates how to use the Tableau Server API +# This script demonstrates how to use the Tableau Server Client # to publish a workbook to a Tableau server. It will publish # a specified workbook to the 'default' project of the given server. # @@ -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.9 or later. +# To run the script, you must have installed Python 2.7.X or 3.3 and later. #### import tableauserverclient as TSC @@ -45,7 +45,7 @@ # 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, server.PublishMode.Overwrite) + new_workbook = server.workbooks.publish(new_workbook, args.filepath, TSC.Server.PublishMode.Overwrite) print("Workbook published. ID: {0}".format(new_workbook.id)) else: error = "The default project could not be found." diff --git a/samples/set_http_options.py b/samples/set_http_options.py index 6700efb3e..dcec24428 100644 --- a/samples/set_http_options.py +++ b/samples/set_http_options.py @@ -2,10 +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. # -# For more information, refer to the documentation 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.9 or later. +# To run the script, you must have installed Python 2.7.X or 3.3 and later. #### import tableauserverclient as TSC From 4c7a46737805970fba76bd4f24b51a7c401a0919 Mon Sep 17 00:00:00 2001 From: Lee Graber Date: Tue, 27 Sep 2016 10:24:00 -0700 Subject: [PATCH 02/32] Updated all _construct_url() to just @property baseurl --- .../server/endpoint/auth_endpoint.py | 6 +++- .../server/endpoint/datasources_endpoint.py | 20 ++++++------- .../server/endpoint/fileuploads_endpoint.py | 10 +++---- .../server/endpoint/groups_endpoint.py | 16 +++++------ .../server/endpoint/projects_endpoint.py | 14 +++++----- .../server/endpoint/sites_endpoint.py | 16 +++++------ .../server/endpoint/users_endpoint.py | 18 ++++++------ .../server/endpoint/views_endpoint.py | 10 +++---- .../server/endpoint/workbooks_endpoint.py | 28 +++++++++---------- test/test_datasource.py | 2 +- test/test_group.py | 2 +- test/test_project.py | 2 +- test/test_request_option.py | 2 +- test/test_site.py | 2 +- test/test_user.py | 2 +- test/test_view.py | 2 +- test/test_workbook.py | 2 +- 17 files changed, 79 insertions(+), 75 deletions(-) diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 951d10ded..c8d66aea7 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -19,9 +19,13 @@ def __exit__(self, exc_type, exc_val, exc_tb): def __init__(self, parent_srv): super(Endpoint, self).__init__() - self.baseurl = "{0}/auth".format(parent_srv.baseurl) self.parent_srv = parent_srv + @property + def baseurl(self): + return "{0}/auth".format(self.parent_srv.baseurl) + + def sign_in(self, auth_req): url = "{0}/{1}".format(self.baseurl, 'signin') signin_req = RequestFactory.Auth.signin_req(auth_req) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 768a85745..ba49c768f 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -18,16 +18,16 @@ class Datasources(Endpoint): def __init__(self, parent_srv): super(Endpoint, self).__init__() - self.baseurl = "{0}/sites/{1}/datasources" self.parent_srv = parent_srv - def _construct_url(self): - return self.baseurl.format(self.parent_srv.baseurl, self.parent_srv.site_id) + @property + def baseurl(self): + return "{0}/sites/{1}/datasources".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all datasources def get(self, req_options=None): logger.info('Querying all datasources on site') - url = self._construct_url() + url = self.baseurl server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content) all_datasource_items = DatasourceItem.from_response(server_response.content) @@ -39,7 +39,7 @@ def get_by_id(self, datasource_id): error = "Datasource ID undefined." raise ValueError(error) logger.info('Querying single datasource (ID: {0})'.format(datasource_id)) - url = "{0}/{1}".format(self._construct_url(), datasource_id) + url = "{0}/{1}".format(self.baseurl, datasource_id) server_response = self.get_request(url) return DatasourceItem.from_response(server_response.content)[0] @@ -48,7 +48,7 @@ def populate_connections(self, datasource_item): if not datasource_item.id: error = 'Datasource item missing ID. Datasource must be retrieved from server first.' raise MissingRequiredFieldError(error) - url = '{0}/{1}/connections'.format(self._construct_url(), datasource_item.id) + url = '{0}/{1}/connections'.format(self.baseurl, datasource_item.id) server_response = self.get_request(url) datasource_item._set_connections(ConnectionItem.from_response(server_response.content)) logger.info('Populated connections for datasource (ID: {0})'.format(datasource_item.id)) @@ -58,7 +58,7 @@ def delete(self, datasource_id): if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self._construct_url(), datasource_id) + url = "{0}/{1}".format(self.baseurl, datasource_id) self.delete_request(url) logger.info('Deleted single datasource (ID: {0})'.format(datasource_id)) @@ -67,7 +67,7 @@ def download(self, datasource_id, filepath=None): if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) - url = "{0}/{1}/content".format(self._construct_url(), datasource_id) + url = "{0}/{1}/content".format(self.baseurl, datasource_id) server_response = self.get_request(url) _, params = cgi.parse_header(server_response.headers['Content-Disposition']) filename = os.path.basename(params['filename']) @@ -86,7 +86,7 @@ def update(self, datasource_item): if not datasource_item.id: error = 'Datasource item missing ID. Datasource must be retrieved from server first.' raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self._construct_url(), 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('Updated datasource item (ID: {0})'.format(datasource_item.id)) @@ -113,7 +113,7 @@ def publish(self, datasource_item, file_path, mode): raise ValueError(error) # Construct the url with the defined mode - url = "{0}?datasourceType={1}".format(self._construct_url(), 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 += '&{0}=true'.format(mode.lower()) diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index d43afc259..65a3b2526 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -14,15 +14,15 @@ class Fileuploads(Endpoint): def __init__(self, parent_srv): super(Endpoint, self).__init__() - self.base_url = "{0}/sites/{1}/fileUploads" self.parent_srv = parent_srv self.upload_id = '' - def _construct_url(self): - return self.base_url.format(self.parent_srv.baseurl, self.parent_srv.site_id) + @property + def baseurl(self): + return "{0}/sites/{1}/fileUploads".format(self.parent_srv.baseurl, self.parent_srv.site_id) def initiate(self): - url = self._construct_url() + url = self.baseurl server_response = self.post_request(url, '') fileupload_item = FileuploadItem.from_response(server_response.content) self.upload_id = fileupload_item.upload_session_id @@ -33,7 +33,7 @@ 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._construct_url(), self.upload_id) + 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)) return FileuploadItem.from_response(server_response.content) diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 9e327cdac..d982ff13a 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -9,16 +9,16 @@ class Groups(Endpoint): def __init__(self, parent_srv): super(Endpoint, self).__init__() - self.baseurl = "{0}/sites/{1}/groups" self.parent_srv = parent_srv - def _construct_url(self): - return self.baseurl.format(self.parent_srv.baseurl, self.parent_srv.site_id) + @property + def baseurl(self): + return "{0}/sites/{1}/groups".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Gets all groups def get(self, req_options=None): logger.info('Querying all groups on site') - url = self._construct_url() + url = self.baseurl server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content) all_group_items = GroupItem.from_response(server_response.content) @@ -29,7 +29,7 @@ def populate_users(self, group_item, req_options=None): if not group_item.id: error = "Group item missing ID. Group must be retrieved from server first." raise MissingRequiredFieldError(error) - url = "{0}/{1}/users".format(self._construct_url(), group_item.id) + url = "{0}/{1}/users".format(self.baseurl, group_item.id) server_response = self.get_request(url, req_options) group_item._set_users(UserItem.from_response(server_response.content)) pagination_item = PaginationItem.from_response(server_response.content) @@ -41,7 +41,7 @@ def delete(self, group_id): if not group_id: error = "Group ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self._construct_url(), group_id) + url = "{0}/{1}".format(self.baseurl, group_id) self.delete_request(url) logger.info('Deleted single group (ID: {0})'.format(group_id)) @@ -54,7 +54,7 @@ def remove_user(self, group_item, user_id): if not user_id: error = "User ID undefined." raise ValueError(error) - url = "{0}/{1}/users/{2}".format(self._construct_url(), group_item.id, user_id) + url = "{0}/{1}/users/{2}".format(self.baseurl, group_item.id, user_id) self.delete_request(url) for user in user_set: if user.id == user_id: @@ -71,7 +71,7 @@ def add_user(self, group_item, user_id): if not user_id: error = "User ID undefined." raise ValueError(error) - url = "{0}/{1}/users".format(self._construct_url(), group_item.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) new_user = UserItem.from_response(server_response.content).pop() diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index ea56650f3..484ee2aff 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -10,15 +10,15 @@ class Projects(Endpoint): def __init__(self, parent_srv): super(Endpoint, self).__init__() - self.baseurl = "{0}/sites/{1}/projects" self.parent_srv = parent_srv - def _construct_url(self): - return self.baseurl.format(self.parent_srv.baseurl, self.parent_srv.site_id) + @property + def baseurl(self): + return "{0}/sites/{1}/projects".format(self.parent_srv.baseurl, self.parent_srv.site_id) def get(self, req_options=None): logger.info('Querying all projects on site') - url = self._construct_url() + url = self.baseurl server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content) all_project_items = ProjectItem.from_response(server_response.content) @@ -28,7 +28,7 @@ def delete(self, project_id): if not project_id: error = "Project ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self._construct_url(), project_id) + url = "{0}/{1}".format(self.baseurl, project_id) self.delete_request(url) logger.info('Deleted single project (ID: {0})'.format(project_id)) @@ -37,7 +37,7 @@ def update(self, project_item): error = "Project item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self._construct_url(), 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) logger.info('Updated project item (ID: {0})'.format(project_item.id)) @@ -45,7 +45,7 @@ def update(self, project_item): return updated_project._parse_common_tags(server_response.content) def create(self, project_item): - url = self._construct_url() + url = self.baseurl create_req = RequestFactory.Project.create_req(project_item) server_response = self.post_request(url, create_req) new_project = ProjectItem.from_response(server_response.content)[0] diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index cd45014ae..704fb9de9 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -10,16 +10,16 @@ class Sites(Endpoint): def __init__(self, parent_srv): super(Endpoint, self).__init__() - self.baseurl = "{0}/sites" self.parent_srv = parent_srv - def _construct_url(self): - return self.baseurl.format(self.parent_srv.baseurl) + @property + def baseurl(self): + return "{0}/sites".format(self.parent_srv.baseurl) # Gets all sites def get(self, req_options=None): logger.info('Querying all sites on site') - url = self._construct_url() + url = self.baseurl server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content) all_site_items = SiteItem.from_response(server_response.content) @@ -31,7 +31,7 @@ def get_by_id(self, site_id): error = "Site ID undefined." raise ValueError(error) logger.info('Querying single site (ID: {0})'.format(site_id)) - url = "{0}/{1}".format(self._construct_url(), site_id) + url = "{0}/{1}".format(self.baseurl, site_id) server_response = self.get_request(url) return SiteItem.from_response(server_response.content)[0] @@ -45,7 +45,7 @@ def update(self, site_item): error = 'You cannot set admin_mode to ContentOnly and also set a user quota' raise ValueError(error) - url = "{0}/{1}".format(self._construct_url(), site_item.id) + 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)) @@ -57,7 +57,7 @@ def delete(self, site_id): if not site_id: error = "Site ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self._construct_url(), site_id) + url = "{0}/{1}".format(self.baseurl, site_id) self.delete_request(url) logger.info('Deleted single site (ID: {0})'.format(site_id)) @@ -68,7 +68,7 @@ def create(self, site_item): error = 'You cannot set admin_mode to ContentOnly and also set a user quota' raise ValueError(error) - url = self._construct_url() + 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)[0] diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 3ae5d6bac..ba5238d19 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -10,16 +10,16 @@ class Users(Endpoint): def __init__(self, parent_srv): super(Endpoint, self).__init__() - self.baseurl = "{0}/sites/{1}/users" self.parent_srv = parent_srv - def _construct_url(self): - return self.baseurl.format(self.parent_srv.baseurl, self.parent_srv.site_id) + @property + def baseurl(self): + return "{0}/sites/{1}/users".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Gets all users def get(self, req_options=None): logger.info('Querying all users on site') - url = self._construct_url() + url = self.baseurl server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content) all_user_items = UserItem.from_response(server_response.content) @@ -31,7 +31,7 @@ def get_by_id(self, user_id): error = "User ID undefined." raise ValueError(error) logger.info('Querying single user (ID: {0})'.format(user_id)) - url = "{0}/{1}".format(self._construct_url(), user_id) + url = "{0}/{1}".format(self.baseurl, user_id) server_response = self.get_request(url) return UserItem.from_response(server_response.content).pop() @@ -41,7 +41,7 @@ def update(self, user_item): error = "User item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self._construct_url(), user_item.id) + url = "{0}/{1}".format(self.baseurl, user_item.id) update_req = RequestFactory.User.update_req(user_item) server_response = self.put_request(url, update_req) logger.info('Updated user item (ID: {0})'.format(user_item.id)) @@ -53,13 +53,13 @@ def remove(self, user_id): if not user_id: error = "User ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self._construct_url(), user_id) + url = "{0}/{1}".format(self.baseurl, user_id) self.delete_request(url) logger.info('Removed single user (ID: {0})'.format(user_id)) # Add new user to site def add(self, user_item): - url = self._construct_url() + url = self.baseurl add_req = RequestFactory.User.add_req(user_item) server_response = self.post_request(url, add_req) new_user = UserItem.from_response(server_response.content).pop() @@ -71,7 +71,7 @@ def populate_workbooks(self, user_item, req_options=None): if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}/workbooks".format(self._construct_url(), user_item.id) + 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)) user_item._set_workbooks(WorkbookItem.from_response(server_response.content)) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 5fde5f500..1ccc418f4 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -9,15 +9,15 @@ class Views(Endpoint): def __init__(self, parent_srv): super(Endpoint, self).__init__() - self.baseurl = "{0}/sites/{1}" self.parent_srv = parent_srv - def _construct_url(self): - return self.baseurl.format(self.parent_srv.baseurl, self.parent_srv.site_id) + @property + def baseurl(self): + return "{0}/sites/{1}".format(self.parent_srv.baseurl, self.parent_srv.site_id) def get(self, req_options=None): logger.info('Querying all views on site') - url = "{0}/views".format(self._construct_url()) + url = "{0}/views".format(self.baseurl) server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content) all_view_items = ViewItem.from_response(server_response.content) @@ -27,7 +27,7 @@ def populate_preview_image(self, view_item): if not view_item.id or not view_item.workbook_id: error = "View item missing ID or workbook ID." raise MissingRequiredFieldError(error) - url = "{0}/workbooks/{1}/views/{2}/previewImage".format(self._construct_url(), + url = "{0}/workbooks/{1}/views/{2}/previewImage".format(self.baseurl, view_item.workbook_id, view_item.id) server_response = self.get_request(url) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 7ef6f6d98..e60789f06 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -19,28 +19,28 @@ class Workbooks(Endpoint): def __init__(self, parent_srv): super(Endpoint, self).__init__() - self.baseurl = "{0}/sites/{1}/workbooks" self.parent_srv = parent_srv - def _construct_url(self): - return self.baseurl.format(self.parent_srv.baseurl, self.parent_srv.site_id) + @property + def baseurl(self): + return "{0}/sites/{1}/workbooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Add new tags to workbook def _add_tags(self, workbook_id, tag_set): - url = "{0}/{1}/tags".format(self._construct_url(), workbook_id) + url = "{0}/{1}/tags".format(self.baseurl, workbook_id) add_req = RequestFactory.Tag.add_req(tag_set) server_response = self.put_request(url, add_req) return TagItem.from_response(server_response.content) # Delete a workbook's tag by name def _delete_tag(self, workbook_id, tag_name): - url = "{0}/{1}/tags/{2}".format(self._construct_url(), workbook_id, tag_name) + url = "{0}/{1}/tags/{2}".format(self.baseurl, workbook_id, tag_name) self.delete_request(url) # Get all workbooks on site def get(self, req_options=None): logger.info('Querying all workbooks on site') - url = self._construct_url() + url = self.baseurl server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content) all_workbook_items = WorkbookItem.from_response(server_response.content) @@ -52,7 +52,7 @@ def get_by_id(self, workbook_id): error = "Workbook ID undefined." raise ValueError(error) logger.info('Querying single workbook (ID: {0})'.format(workbook_id)) - url = "{0}/{1}".format(self._construct_url(), workbook_id) + url = "{0}/{1}".format(self.baseurl, workbook_id) server_response = self.get_request(url) return WorkbookItem.from_response(server_response.content)[0] @@ -61,7 +61,7 @@ def delete(self, workbook_id): if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self._construct_url(), workbook_id) + url = "{0}/{1}".format(self.baseurl, workbook_id) self.delete_request(url) logger.info('Deleted single workbook (ID: {0})'.format(workbook_id)) @@ -83,7 +83,7 @@ def update(self, workbook_item): logger.info('Updated workbook tags to {0}'.format(workbook_item.tags)) # Update the workbook itself - url = "{0}/{1}".format(self._construct_url(), workbook_item.id) + 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)) @@ -95,7 +95,7 @@ def download(self, workbook_id, filepath=None): if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) - url = "{0}/{1}/content".format(self._construct_url(), workbook_id) + url = "{0}/{1}/content".format(self.baseurl, workbook_id) server_response = self.get_request(url) _, params = cgi.parse_header(server_response.headers['Content-Disposition']) filename = os.path.basename(params['filename']) @@ -114,7 +114,7 @@ def populate_views(self, workbook_item): if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) - url = "{0}/{1}/views".format(self._construct_url(), workbook_item.id) + url = "{0}/{1}/views".format(self.baseurl, workbook_item.id) server_response = self.get_request(url) workbook_item._set_views(ViewItem.from_response(server_response.content, workbook_id=workbook_item.id)) logger.info('Populated views for workbook (ID: {0}'.format(workbook_item.id)) @@ -124,7 +124,7 @@ def populate_connections(self, workbook_item): if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) - url = "{0}/{1}/connections".format(self._construct_url(), workbook_item.id) + url = "{0}/{1}/connections".format(self.baseurl, workbook_item.id) server_response = self.get_request(url) workbook_item._set_connections(ConnectionItem.from_response(server_response.content)) logger.info('Populated connections for workbook (ID: {0})'.format(workbook_item.id)) @@ -134,7 +134,7 @@ def populate_preview_image(self, workbook_item): if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) - url = "{0}/{1}/previewImage".format(self._construct_url(), workbook_item.id) + url = "{0}/{1}/previewImage".format(self.baseurl, workbook_item.id) server_response = self.get_request(url) workbook_item._set_preview_image(server_response.content) logger.info('Populated preview image for workbook (ID: {0})'.format(workbook_item.id)) @@ -159,7 +159,7 @@ def publish(self, workbook_item, file_path, mode): raise ValueError(error) # Construct the url with the defined mode - url = "{0}?workbookType={1}".format(self._construct_url(), file_extension) + url = "{0}?workbookType={1}".format(self.baseurl, file_extension) if mode == self.parent_srv.PublishMode.Overwrite: url += '&{0}=true'.format(mode.lower()) elif mode == self.parent_srv.PublishMode.Append: diff --git a/test/test_datasource.py b/test/test_datasource.py index c85440b4d..d01f3cb0f 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -20,7 +20,7 @@ def setUp(self): self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' - self.baseurl = self.server.datasources._construct_url() + self.baseurl = self.server.datasources.baseurl def test_get(self): with open(GET_XML, 'rb') as f: diff --git a/test/test_group.py b/test/test_group.py index 8ad4627af..a521c0d9a 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -18,7 +18,7 @@ def setUp(self): self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' - self.baseurl = self.server.groups._construct_url() + self.baseurl = self.server.groups.baseurl def test_get(self): with open(GET_XML, 'rb') as f: diff --git a/test/test_project.py b/test/test_project.py index e7439e96f..a099e1a11 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -18,7 +18,7 @@ def setUp(self): self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' - self.baseurl = self.server.projects._construct_url() + self.baseurl = self.server.projects.baseurl def test_get(self): with open(GET_XML, 'rb') as f: diff --git a/test/test_request_option.py b/test/test_request_option.py index c5dd76805..37abaf543 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -20,7 +20,7 @@ def setUp(self): self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' - self.baseurl = '{0}/{1}'.format(self.server.sites._construct_url(), 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: diff --git a/test/test_site.py b/test/test_site.py index 6f675ce94..3076e4ce3 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -18,7 +18,7 @@ def setUp(self): # Fake signin self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' - self.baseurl = self.server.sites._construct_url() + self.baseurl = self.server.sites.baseurl def test_get(self): with open(GET_XML, 'rb') as f: diff --git a/test/test_user.py b/test/test_user.py index c45718e14..ae0357cbe 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -22,7 +22,7 @@ def setUp(self): self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' - self.baseurl = self.server.users._construct_url() + self.baseurl = self.server.users.baseurl def test_get(self): with open(GET_XML, 'rb') as f: diff --git a/test/test_view.py b/test/test_view.py index fa77a842f..1ecdec7a2 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -17,7 +17,7 @@ def setUp(self): self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' - self.baseurl = self.server.views._construct_url() + self.baseurl = self.server.views.baseurl def test_get(self): with open(GET_XML, 'rb') as f: diff --git a/test/test_workbook.py b/test/test_workbook.py index 23d28cba0..e99d07f81 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -24,7 +24,7 @@ def setUp(self): self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' - self.baseurl = self.server.workbooks._construct_url() + self.baseurl = self.server.workbooks.baseurl def test_get(self): with open(GET_XML, 'rb') as f: From 36d63c05c02535929b1ffb376ba008bab955aee0 Mon Sep 17 00:00:00 2001 From: Lee Graber Date: Tue, 27 Sep 2016 10:29:46 -0700 Subject: [PATCH 03/32] Fixed pep8 issue (extra empty line) --- tableauserverclient/server/endpoint/auth_endpoint.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index c8d66aea7..e685effbe 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -25,7 +25,6 @@ def __init__(self, parent_srv): def baseurl(self): return "{0}/auth".format(self.parent_srv.baseurl) - def sign_in(self, auth_req): url = "{0}/{1}".format(self.baseurl, 'signin') signin_req = RequestFactory.Auth.signin_req(auth_req) From e9aa592559af4e7609951dcb6ce1a82fbef110f0 Mon Sep 17 00:00:00 2001 From: Lee Graber Date: Wed, 28 Sep 2016 16:38:17 -0700 Subject: [PATCH 04/32] Add property decorators --- tableauserverclient/models/datasource_item.py | 8 ++- tableauserverclient/models/group_item.py | 8 ++- tableauserverclient/models/project_item.py | 16 +++--- tableauserverclient/models/site_item.py | 52 ++++++------------- tableauserverclient/models/user_item.py | 27 ++++------ tableauserverclient/models/workbook_item.py | 16 +++--- 6 files changed, 44 insertions(+), 83 deletions(-) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 9f65f5419..17443faba 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -1,5 +1,6 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError +from .property_not_nullable_decorator import * from .tag_item import TagItem from .. import NAMESPACE @@ -45,12 +46,9 @@ def project_id(self): return self._project_id @project_id.setter + @property_not_nullable def project_id(self, value): - if value is None: - error = 'Project ID must be defined.' - raise ValueError(error) - else: - self._project_id = value + self._project_id = value @property def project_name(self): diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 9e6de73cd..0342233ac 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -1,5 +1,6 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError +from .property_not_nullable_decorator import * from .. import NAMESPACE @@ -26,12 +27,9 @@ def name(self): return self._name @name.setter + @property_not_nullable def name(self, value): - if not value: - error = 'Name must be defined.' - raise ValueError(error) - else: - self._name = value + self._name = value @property def users(self): diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 768d5f16e..fe0c50c63 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,4 +1,6 @@ import xml.etree.ElementTree as ET +from .property_not_nullable_decorator import * +from .property_type_decorator import * from .. import NAMESPACE @@ -26,12 +28,9 @@ def content_permissions(self): return self._content_permissions @content_permissions.setter + @property_type(ContentPermissions) def content_permissions(self, value): - if value and not hasattr(ProjectItem.ContentPermissions, value): - error = 'Invalid content permission defined.' - raise ValueError(error) - else: - self._content_permissions = value + self._content_permissions = value @property def id(self): @@ -42,12 +41,9 @@ def name(self): return self._name @name.setter + @property_not_nullable def name(self, value): - if not value: - error = 'Name must be defined.' - raise ValueError(error) - else: - self._name = value + self._name = value def is_default(self): return self.name.lower() == 'default' diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index f2791ed6a..62613b206 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -1,4 +1,6 @@ import xml.etree.ElementTree as ET +from .property_type_decorator import * +from .property_not_nullable_decorator import * from .. import NAMESPACE @@ -45,36 +47,27 @@ def admin_mode(self): return self._admin_mode @admin_mode.setter + @property_type(AdminMode) def admin_mode(self, value): - if value and not hasattr(SiteItem.AdminMode, value): - error = 'Invalid admin mode defined.' - raise ValueError(error) - else: - self._admin_mode = value + self._admin_mode = value @property def content_url(self): return self._content_url @content_url.setter + @property_not_nullable def content_url(self, value): - if value is None: - error = 'Content URL must be defined.' - raise ValueError(error) - else: - self._content_url = value + self._content_url = value @property def disable_subscriptions(self): return self._disable_subscriptions @disable_subscriptions.setter + @property_type_boolean def disable_subscriptions(self, value): - if not isinstance(value, bool): - error = 'Boolean expected for disable_subscriptions flag.' - raise ValueError(error) - else: - self._disable_subscriptions = value + self._disable_subscriptions = value @property def id(self): @@ -85,12 +78,9 @@ def name(self): return self._name @name.setter + @property_not_nullable def name(self, value): - if not value: - error = 'Name must be defined.' - raise ValueError(error) - else: - self._name = value + self._name = value @property def num_users(self): @@ -101,24 +91,19 @@ def revision_history_enabled(self): return self._revision_history_enabled @revision_history_enabled.setter + @property_type_boolean def revision_history_enabled(self, value): - if not isinstance(value, bool): - error = 'Boolean expected for revision_history_enabled flag.' - raise ValueError(error) - else: - self._revision_history_enabled = value + self._revision_history_enabled = value @property def state(self): return self._state @state.setter + @property_not_nullable + @property_type(State) def state(self, value): - if not hasattr(SiteItem.State, value): - error = 'Invalid state defined.' - raise ValueError(error) - else: - self._state = value + self._state = value @property def status_reason(self): @@ -133,12 +118,9 @@ def subscribe_others_enabled(self): return self._subscribe_others_enabled @subscribe_others_enabled.setter + @property_type_boolean def subscribe_others_enabled(self, value): - if not isinstance(value, bool): - error = 'Boolean expected for subscribe_others_enabled flag.' - raise ValueError(error) - else: - self._subscribe_others_enabled = value + self._subscribe_others_enabled = value def is_default(self): return self.name.lower() == 'default' diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index a63db6908..f13d408a5 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -1,5 +1,7 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError +from .property_type_decorator import * +from .property_not_nullable_decorator import * from .. import NAMESPACE @@ -45,12 +47,9 @@ def auth_setting(self): return self._auth_setting @auth_setting.setter + @property_type(Auth) def auth_setting(self, value): - if not hasattr(UserItem.Auth, value): - error = 'Invalid auth setting defined.' - raise ValueError(error) - else: - self._auth_setting = value + self._auth_setting = value @property def domain_name(self): @@ -73,27 +72,19 @@ def name(self): return self._name @name.setter + @property_not_nullable def name(self, value): - if not value: - error = 'Name must be defined.' - raise ValueError(error) - else: - self._name = value + self._name = value @property def site_role(self): return self._site_role @site_role.setter + @property_not_nullable + @property_type(Roles) def site_role(self, value): - if not value: - error = 'Site role must be defined.' - raise ValueError(error) - elif not hasattr(UserItem.Roles, value): - error = 'Invalid site role defined.' - raise ValueError(error) - else: - self._site_role = value + self._site_role = value @property def workbooks(self): diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 01d1b5109..03adb6384 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -1,5 +1,7 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError +from .property_not_nullable_decorator import * +from property_type_decorator import * from .tag_item import TagItem from .view_item import ViewItem from .. import NAMESPACE @@ -59,12 +61,9 @@ def project_id(self): return self._project_id @project_id.setter + @property_not_nullable def project_id(self, value): - if value is None: - error = 'Project ID must be defined.' - raise ValueError(error) - else: - self._project_id = value + self._project_id = value @property def project_name(self): @@ -75,12 +74,9 @@ def show_tabs(self): return self._show_tabs @show_tabs.setter + @property_type_boolean def show_tabs(self, value): - if not isinstance(value, bool): - error = 'Boolean expected for show tabs flag.' - raise ValueError(error) - else: - self._show_tabs = value + self._show_tabs = value @property def size(self): From 689773825f96bafad96f986836893fbb308cfdec Mon Sep 17 00:00:00 2001 From: Lee Graber Date: Wed, 28 Sep 2016 16:39:18 -0700 Subject: [PATCH 05/32] Add decorator files --- .../models/property_not_nullable_decorator.py | 12 +++++++++ .../models/property_type_decorator.py | 25 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 tableauserverclient/models/property_not_nullable_decorator.py create mode 100644 tableauserverclient/models/property_type_decorator.py diff --git a/tableauserverclient/models/property_not_nullable_decorator.py b/tableauserverclient/models/property_not_nullable_decorator.py new file mode 100644 index 000000000..bed437184 --- /dev/null +++ b/tableauserverclient/models/property_not_nullable_decorator.py @@ -0,0 +1,12 @@ +from functools import wraps + + +def property_not_nullable(func): + @wraps(func) + def wrapper(self, value): + if value is None: + error = "{0} must be defined.".format(func.func_name) + raise ValueError(error) + return func(self, value) + + return wrapper diff --git a/tableauserverclient/models/property_type_decorator.py b/tableauserverclient/models/property_type_decorator.py new file mode 100644 index 000000000..adbe18c83 --- /dev/null +++ b/tableauserverclient/models/property_type_decorator.py @@ -0,0 +1,25 @@ +from functools import wraps + + +def property_type(enum_type): + def property_type_decorator(func): + @wraps(func) + def wrapper(self, value): + if value and not hasattr(enum_type, value): + error = "Invalid value: {0}. {1} must be of type {2}.".format(value, func.func_name, enum_type.__name__) + raise ValueError(error) + return func(self, value) + + return wrapper + + return property_type_decorator + + +def property_type_boolean(func): + @wraps(func) + def wrapper(self, value): + if not isinstance(value, bool): + error = "Boolean expected for {0} flag.".format(func.func_name) + return func(self, value) + + return wrapper From 1a0b945f50cfff2adf5bcb11fe0cb14703888c74 Mon Sep 17 00:00:00 2001 From: Lee Graber Date: Thu, 29 Sep 2016 00:55:44 -0700 Subject: [PATCH 06/32] Add test cases and combine decorators into one file --- tableauserverclient/models/datasource_item.py | 2 +- tableauserverclient/models/group_item.py | 4 +- tableauserverclient/models/project_item.py | 5 +- ...pe_decorator.py => property_decorators.py} | 25 ++++++++- .../models/property_not_nullable_decorator.py | 12 ----- tableauserverclient/models/site_item.py | 6 +-- tableauserverclient/models/user_item.py | 5 +- tableauserverclient/models/workbook_item.py | 3 +- test/test_datasource_model.py | 10 ++++ test/test_group_model.py | 14 +++++ test/test_project_model.py | 19 +++++++ test/test_site_model.py | 54 +++++++++++++++++++ test/test_user_model.py | 24 +++++++++ test/test_workbook_model.py | 19 +++++++ 14 files changed, 174 insertions(+), 28 deletions(-) rename tableauserverclient/models/{property_type_decorator.py => property_decorators.py} (51%) delete mode 100644 tableauserverclient/models/property_not_nullable_decorator.py create mode 100644 test/test_datasource_model.py create mode 100644 test/test_group_model.py create mode 100644 test/test_project_model.py create mode 100644 test/test_site_model.py create mode 100644 test/test_user_model.py create mode 100644 test/test_workbook_model.py diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 17443faba..0d33fe410 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_not_nullable_decorator import * +from .property_decorators import * from .tag_item import TagItem from .. import NAMESPACE diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 0342233ac..c94d23fbe 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_not_nullable_decorator import * +from .property_decorators import * from .. import NAMESPACE @@ -27,7 +27,7 @@ def name(self): return self._name @name.setter - @property_not_nullable + @property_not_empty def name(self, value): self._name = value diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index fe0c50c63..8cac8caf2 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,6 +1,5 @@ import xml.etree.ElementTree as ET -from .property_not_nullable_decorator import * -from .property_type_decorator import * +from .property_decorators import * from .. import NAMESPACE @@ -41,7 +40,7 @@ def name(self): return self._name @name.setter - @property_not_nullable + @property_not_empty def name(self, value): self._name = value diff --git a/tableauserverclient/models/property_type_decorator.py b/tableauserverclient/models/property_decorators.py similarity index 51% rename from tableauserverclient/models/property_type_decorator.py rename to tableauserverclient/models/property_decorators.py index adbe18c83..b54da0112 100644 --- a/tableauserverclient/models/property_type_decorator.py +++ b/tableauserverclient/models/property_decorators.py @@ -5,7 +5,7 @@ def property_type(enum_type): def property_type_decorator(func): @wraps(func) def wrapper(self, value): - if value and not hasattr(enum_type, value): + if not value is None and not hasattr(enum_type, value): error = "Invalid value: {0}. {1} must be of type {2}.".format(value, func.func_name, enum_type.__name__) raise ValueError(error) return func(self, value) @@ -20,6 +20,29 @@ def property_type_boolean(func): def wrapper(self, value): if not isinstance(value, bool): error = "Boolean expected for {0} flag.".format(func.func_name) + raise ValueError(error) + return func(self, value) + + return wrapper + + +def property_not_nullable(func): + @wraps(func) + def wrapper(self, value): + if value is None: + error = "{0} must be defined.".format(func.func_name) + raise ValueError(error) + return func(self, value) + + return wrapper + + +def property_not_empty(func): + @wraps(func) + def wrapper(self, value): + if not value: + error = "{0} must be defined.".format(func.func_name) + raise ValueError(error) return func(self, value) return wrapper diff --git a/tableauserverclient/models/property_not_nullable_decorator.py b/tableauserverclient/models/property_not_nullable_decorator.py deleted file mode 100644 index bed437184..000000000 --- a/tableauserverclient/models/property_not_nullable_decorator.py +++ /dev/null @@ -1,12 +0,0 @@ -from functools import wraps - - -def property_not_nullable(func): - @wraps(func) - def wrapper(self, value): - if value is None: - error = "{0} must be defined.".format(func.func_name) - raise ValueError(error) - return func(self, value) - - return wrapper diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 62613b206..5e5541930 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -1,6 +1,5 @@ import xml.etree.ElementTree as ET -from .property_type_decorator import * -from .property_not_nullable_decorator import * +from .property_decorators import * from .. import NAMESPACE @@ -78,7 +77,7 @@ def name(self): return self._name @name.setter - @property_not_nullable + @property_not_empty def name(self, value): self._name = value @@ -100,7 +99,6 @@ def state(self): return self._state @state.setter - @property_not_nullable @property_type(State) def state(self, value): self._state = value diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index f13d408a5..1fbb33d7e 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -1,7 +1,6 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_type_decorator import * -from .property_not_nullable_decorator import * +from .property_decorators import * from .. import NAMESPACE @@ -72,7 +71,7 @@ def name(self): return self._name @name.setter - @property_not_nullable + @property_not_empty def name(self, value): self._name = value diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 03adb6384..1421e3b34 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -1,7 +1,6 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_not_nullable_decorator import * -from property_type_decorator import * +from .property_decorators import * from .tag_item import TagItem from .view_item import ViewItem from .. import NAMESPACE diff --git a/test/test_datasource_model.py b/test/test_datasource_model.py new file mode 100644 index 000000000..b43cc3f3d --- /dev/null +++ b/test/test_datasource_model.py @@ -0,0 +1,10 @@ +import unittest +import tableauserverclient as TSC + + +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 diff --git a/test/test_group_model.py b/test/test_group_model.py new file mode 100644 index 000000000..eb11adcdd --- /dev/null +++ b/test/test_group_model.py @@ -0,0 +1,14 @@ +import unittest +import tableauserverclient as TSC + + +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 = "" diff --git a/test/test_project_model.py b/test/test_project_model.py new file mode 100644 index 000000000..9387d19fa --- /dev/null +++ b/test/test_project_model.py @@ -0,0 +1,19 @@ +import unittest +import tableauserverclient as TSC + + +class ProjectModelTests(unittest.TestCase): + def test_invalid_name(self): + self.assertRaises(ValueError, TSC.ProjectItem, None) + self.assertRaises(ValueError, TSC.ProjectItem, "") + project = TSC.ProjectItem("proj") + with self.assertRaises(ValueError): + project.name = None + + with self.assertRaises(ValueError): + project.name = "" + + def test_invalid_content_permissions(self): + project = TSC.ProjectItem("proj") + with self.assertRaises(ValueError): + project.content_permissions = "Hello" \ No newline at end of file diff --git a/test/test_site_model.py b/test/test_site_model.py new file mode 100644 index 000000000..02735ed8c --- /dev/null +++ b/test/test_site_model.py @@ -0,0 +1,54 @@ +import unittest +import tableauserverclient as TSC + + +class SiteModelTests(unittest.TestCase): + def test_invalid_name(self): + self.assertRaises(ValueError, TSC.SiteItem, None, "url") + self.assertRaises(ValueError, TSC.SiteItem, "", "url") + site = TSC.SiteItem("site", "url") + with self.assertRaises(ValueError): + site.name = None + + with self.assertRaises(ValueError): + site.name = "" + + def test_invalid_admin_mode(self): + site = TSC.SiteItem("site", "url") + with self.assertRaises(ValueError): + site.admin_mode = "Hello" + + def test_invalid_content_url(self): + self.assertRaises(ValueError, TSC.SiteItem, "site", None) + site = TSC.SiteItem("site", "url") + with self.assertRaises(ValueError): + site.content_url = None + + def test_invalid_disable_subscriptions(self): + site = TSC.SiteItem("site", "url") + with self.assertRaises(ValueError): + site.disable_subscriptions = "Hello" + + with self.assertRaises(ValueError): + site.disable_subscriptions = None + + def test_invalid_revision_history_enabled(self): + site = TSC.SiteItem("site", "url") + with self.assertRaises(ValueError): + site.revision_history_enabled = "Hello" + + with self.assertRaises(ValueError): + site.revision_history_enabled = None + + def test_invalid_state(self): + site = TSC.SiteItem("site", "url") + with self.assertRaises(ValueError): + site.state = "Hello" + + def test_invalid_subscribe_others_enabled(self): + site = TSC.SiteItem("site", "url") + with self.assertRaises(ValueError): + site.subscribe_others_enabled = "Hello" + + with self.assertRaises(ValueError): + site.subscribe_others_enabled = None diff --git a/test/test_user_model.py b/test/test_user_model.py new file mode 100644 index 000000000..5826fb148 --- /dev/null +++ b/test/test_user_model.py @@ -0,0 +1,24 @@ +import unittest +import tableauserverclient as TSC + + +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): + user.auth_setting = "Hello" + + def test_invalid_site_role(self): + user = TSC.UserItem("me", TSC.UserItem.Roles.Publisher) + with self.assertRaises(ValueError): + user.site_role = "Hello" diff --git a/test/test_workbook_model.py b/test/test_workbook_model.py new file mode 100644 index 000000000..9cce7fb73 --- /dev/null +++ b/test/test_workbook_model.py @@ -0,0 +1,19 @@ +import unittest +import tableauserverclient as TSC + + +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): + workbook.show_tabs = "Hello" + + with self.assertRaises(ValueError): + workbook.show_tabs = None \ No newline at end of file From 5ead14743656e56a898f6afe0e0f40550ecd4230 Mon Sep 17 00:00:00 2001 From: Lee Graber Date: Thu, 29 Sep 2016 09:46:32 -0700 Subject: [PATCH 07/32] Remove * and be explicit on imports --- tableauserverclient/models/datasource_item.py | 2 +- tableauserverclient/models/group_item.py | 2 +- tableauserverclient/models/project_item.py | 2 +- tableauserverclient/models/site_item.py | 2 +- tableauserverclient/models/user_item.py | 2 +- tableauserverclient/models/workbook_item.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 0d33fe410..68b4e5a87 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_decorators import * +from .property_decorators import property_not_nullable from .tag_item import TagItem from .. import NAMESPACE diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index c94d23fbe..e4be03b78 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_decorators import * +from .property_decorators import property_not_empty from .. import NAMESPACE diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 8cac8caf2..b8807fed1 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,5 +1,5 @@ import xml.etree.ElementTree as ET -from .property_decorators import * +from .property_decorators import property_type, property_not_empty from .. import NAMESPACE diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 5e5541930..0ce28a35d 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -1,5 +1,5 @@ import xml.etree.ElementTree as ET -from .property_decorators import * +from .property_decorators import property_type, property_type_boolean, property_not_empty, property_not_nullable from .. import NAMESPACE diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 1fbb33d7e..08379d79d 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_decorators import * +from .property_decorators import property_type, property_not_empty, property_not_nullable from .. import NAMESPACE diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 1421e3b34..906a1cde2 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 * +from .property_decorators import property_not_nullable, property_type_boolean from .tag_item import TagItem from .view_item import ViewItem from .. import NAMESPACE From 48a5f4962fdce70b32706107eb70c1427b9905cc Mon Sep 17 00:00:00 2001 From: Lee Graber Date: Thu, 29 Sep 2016 09:48:53 -0700 Subject: [PATCH 08/32] Ignore PyCharm related files --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 251ef5d25..6e1779e11 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,9 @@ docs/_build/ # PyBuilder target/ +# PyCharm stuff +.idea/ + # IPython Notebook .ipynb_checkpoints From 8c17ecc5cc74938cf66799c7bdd47c859ef9ab09 Mon Sep 17 00:00:00 2001 From: Lee Graber Date: Thu, 29 Sep 2016 10:05:11 -0700 Subject: [PATCH 09/32] Fix Travis errors. Fix error text for property_not_empty --- tableauserverclient/models/property_decorators.py | 10 +++++----- test/test_project_model.py | 3 ++- test/test_workbook_model.py | 3 ++- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index b54da0112..11d3f37d1 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -5,8 +5,8 @@ def property_type(enum_type): def property_type_decorator(func): @wraps(func) def wrapper(self, value): - if not value is None and not hasattr(enum_type, value): - error = "Invalid value: {0}. {1} must be of type {2}.".format(value, func.func_name, enum_type.__name__) + 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__) raise ValueError(error) return func(self, value) @@ -19,7 +19,7 @@ def property_type_boolean(func): @wraps(func) def wrapper(self, value): if not isinstance(value, bool): - error = "Boolean expected for {0} flag.".format(func.func_name) + error = "Boolean expected for {0} flag.".format(func.__name__) raise ValueError(error) return func(self, value) @@ -30,7 +30,7 @@ def property_not_nullable(func): @wraps(func) def wrapper(self, value): if value is None: - error = "{0} must be defined.".format(func.func_name) + error = "{0} must be defined.".format(func.__name__) raise ValueError(error) return func(self, value) @@ -41,7 +41,7 @@ def property_not_empty(func): @wraps(func) def wrapper(self, value): if not value: - error = "{0} must be defined.".format(func.func_name) + error = "{0} must not be empty.".format(func.__name__) raise ValueError(error) return func(self, value) diff --git a/test/test_project_model.py b/test/test_project_model.py index 9387d19fa..e6684f6bb 100644 --- a/test/test_project_model.py +++ b/test/test_project_model.py @@ -16,4 +16,5 @@ def test_invalid_name(self): def test_invalid_content_permissions(self): project = TSC.ProjectItem("proj") with self.assertRaises(ValueError): - project.content_permissions = "Hello" \ No newline at end of file + project.content_permissions = "Hello" + diff --git a/test/test_workbook_model.py b/test/test_workbook_model.py index 9cce7fb73..5686df1dc 100644 --- a/test/test_workbook_model.py +++ b/test/test_workbook_model.py @@ -16,4 +16,5 @@ def test_invalid_show_tabs(self): workbook.show_tabs = "Hello" with self.assertRaises(ValueError): - workbook.show_tabs = None \ No newline at end of file + workbook.show_tabs = None + \ No newline at end of file From 05ff9fd9bc0c0bbcc1a1d024bcb06e7dad1b2010 Mon Sep 17 00:00:00 2001 From: Lee Graber Date: Thu, 29 Sep 2016 10:10:21 -0700 Subject: [PATCH 10/32] Trying again to fix travis complaints --- test/test_project_model.py | 1 - test/test_workbook_model.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/test/test_project_model.py b/test/test_project_model.py index e6684f6bb..3ab14b3f6 100644 --- a/test/test_project_model.py +++ b/test/test_project_model.py @@ -17,4 +17,3 @@ def test_invalid_content_permissions(self): project = TSC.ProjectItem("proj") with self.assertRaises(ValueError): project.content_permissions = "Hello" - diff --git a/test/test_workbook_model.py b/test/test_workbook_model.py index 5686df1dc..69188fa4a 100644 --- a/test/test_workbook_model.py +++ b/test/test_workbook_model.py @@ -3,7 +3,6 @@ class WorkbookModelTests(unittest.TestCase): - def test_invalid_project_id(self): self.assertRaises(ValueError, TSC.WorkbookItem, None) workbook = TSC.WorkbookItem("10") @@ -17,4 +16,3 @@ def test_invalid_show_tabs(self): with self.assertRaises(ValueError): workbook.show_tabs = None - \ No newline at end of file From a13d708d44b875efcade62912a60fd594c8e20bc Mon Sep 17 00:00:00 2001 From: Lee Graber Date: Fri, 30 Sep 2016 10:16:19 -0700 Subject: [PATCH 11/32] Rename property decorators --- tableauserverclient/models/project_item.py | 4 ++-- tableauserverclient/models/property_decorators.py | 4 ++-- tableauserverclient/models/site_item.py | 12 ++++++------ tableauserverclient/models/user_item.py | 6 +++--- tableauserverclient/models/workbook_item.py | 4 ++-- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index b8807fed1..70585b479 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,5 +1,5 @@ import xml.etree.ElementTree as ET -from .property_decorators import property_type, property_not_empty +from .property_decorators import property_is_enum, property_not_empty from .. import NAMESPACE @@ -27,7 +27,7 @@ def content_permissions(self): return self._content_permissions @content_permissions.setter - @property_type(ContentPermissions) + @property_is_enum(ContentPermissions) def content_permissions(self, value): self._content_permissions = value diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 11d3f37d1..5a1b7560e 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,7 +1,7 @@ from functools import wraps -def property_type(enum_type): +def property_is_enum(enum_type): def property_type_decorator(func): @wraps(func) def wrapper(self, value): @@ -15,7 +15,7 @@ def wrapper(self, value): return property_type_decorator -def property_type_boolean(func): +def property_is_boolean(func): @wraps(func) def wrapper(self, value): if not isinstance(value, bool): diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 0ce28a35d..9d742e8fc 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -1,5 +1,5 @@ import xml.etree.ElementTree as ET -from .property_decorators import property_type, property_type_boolean, property_not_empty, property_not_nullable +from .property_decorators import property_is_enum, property_is_boolean, property_not_empty, property_not_nullable from .. import NAMESPACE @@ -46,7 +46,7 @@ def admin_mode(self): return self._admin_mode @admin_mode.setter - @property_type(AdminMode) + @property_is_enum(AdminMode) def admin_mode(self, value): self._admin_mode = value @@ -64,7 +64,7 @@ def disable_subscriptions(self): return self._disable_subscriptions @disable_subscriptions.setter - @property_type_boolean + @property_is_boolean def disable_subscriptions(self, value): self._disable_subscriptions = value @@ -90,7 +90,7 @@ def revision_history_enabled(self): return self._revision_history_enabled @revision_history_enabled.setter - @property_type_boolean + @property_is_boolean def revision_history_enabled(self, value): self._revision_history_enabled = value @@ -99,7 +99,7 @@ def state(self): return self._state @state.setter - @property_type(State) + @property_is_enum(State) def state(self, value): self._state = value @@ -116,7 +116,7 @@ def subscribe_others_enabled(self): return self._subscribe_others_enabled @subscribe_others_enabled.setter - @property_type_boolean + @property_is_boolean def subscribe_others_enabled(self, value): self._subscribe_others_enabled = value diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 08379d79d..16c5d3b72 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_type, property_not_empty, property_not_nullable +from .property_decorators import property_is_enum, property_not_empty, property_not_nullable from .. import NAMESPACE @@ -46,7 +46,7 @@ def auth_setting(self): return self._auth_setting @auth_setting.setter - @property_type(Auth) + @property_is_enum(Auth) def auth_setting(self, value): self._auth_setting = value @@ -81,7 +81,7 @@ def site_role(self): @site_role.setter @property_not_nullable - @property_type(Roles) + @property_is_enum(Roles) def site_role(self, value): self._site_role = value diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 906a1cde2..133959d5d 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_type_boolean +from .property_decorators import property_not_nullable, property_is_boolean from .tag_item import TagItem from .view_item import ViewItem from .. import NAMESPACE @@ -73,7 +73,7 @@ def show_tabs(self): return self._show_tabs @show_tabs.setter - @property_type_boolean + @property_is_boolean def show_tabs(self, value): self._show_tabs = value From ab6b664daec593141e03c99021164583c6b3d2c4 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Sun, 2 Oct 2016 16:19:08 -0500 Subject: [PATCH 12/32] Cleaning up samples (#47) * Cleaning up samples * Wrap everything in a main function and ensure spaces for indentation * Kill a few unused imports and a few minor idiomatic tweaks --- samples/explore_datasource.py | 92 ++++++++-------- samples/explore_workbook.py | 173 ++++++++++++++++-------------- samples/move_workbook_projects.py | 92 +++++++++------- samples/move_workbook_sites.py | 152 ++++++++++++++------------ samples/publish_workbook.py | 76 +++++++------ samples/set_http_options.py | 53 +++++---- 6 files changed, 349 insertions(+), 289 deletions(-) diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index 601e0f2b5..260742cd4 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -9,56 +9,64 @@ # on top of the general operations. #### - -import tableauserverclient as TSC -import os.path import argparse import getpass import logging -parser = argparse.ArgumentParser(description='Explore datasource 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('--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('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') -args = parser.parse_args() +import tableauserverclient as TSC + + +def main(): + + parser = argparse.ArgumentParser(description='Explore datasource 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('--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('--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) -password = getpass.getpass("Password: ") + # SIGN IN + tableau_auth = TSC.TableauAuth(args.username, password) + server = TSC.Server(args.server) + with server.auth.sign_in(tableau_auth): + # Query projects for use when demonstrating publishing and updating + all_projects, pagination_item = server.projects.get() + default_project = next((project for project in all_projects if project.is_default()), None) -# Set logging level based on user input, or error by default -logging_level = getattr(logging, args.logging_level.upper()) -logging.basicConfig(level=logging_level) + # Publish datasource if publish flag is set (-publish, -p) + if args.publish: + 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) + print("Datasource published. ID: {}".format(new_datasource.id)) + else: + print("Publish failed. Could not find the default project.") -# SIGN IN -tableau_auth = TSC.TableauAuth(args.username, password) -server = TSC.Server(args.server) -with server.auth.sign_in(tableau_auth): - # Query projects for use when demonstrating publishing and updating - all_projects, pagination_item = server.projects.get() - default_project = next((project for project in all_projects if project.is_default()), None) + # Gets all datasource items + 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]) - # Publish datasource if publish flag is set (-publish, -p) - if args.publish: - 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) - print("Datasource published. ID: {}".format(new_datasource.id)) - else: - print("Publish failed. Could not find the default project.") + if all_datasources: + # Pick one datasource from the list + sample_datasource = all_datasources[0] - # Gets all datasource items - 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]) + # 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]) - if all_datasources: - # Pick one datasource from the list - sample_datasource = all_datasources[0] - # 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]) +if __name__ == '__main__': + main() diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index 35bd4222c..6cdb2b1a2 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -9,88 +9,99 @@ # on top of the general operations. #### -import tableauserverclient as TSC -import os.path -import copy import argparse import getpass import logging +import os.path + +import tableauserverclient as TSC + + +def main(): + + parser = argparse.ArgumentParser(description='Explore workbook 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('--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('--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) + + # SIGN IN + tableau_auth = TSC.TableauAuth(args.username, password) + server = TSC.Server(args.server) + + overwrite_true = TSC.Server.PublishMode.Overwrite + + with server.auth.sign_in(tableau_auth): + + # Publish workbook if publish flag is set (-publish, -p) + if args.publish: + all_projects, pagination_item = server.projects.get() + default_project = next((project for project in all_projects if project.is_default()), None) + + 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)) + 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([workbook.name for workbook in all_workbooks]) + + if all_workbooks: + # Pick one workbook from the list + sample_workbook = all_workbooks[0] + + # Populate views + server.workbooks.populate_views(sample_workbook) + 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("\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("\nOld tag set: {}".format(original_tag_set)) + print("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 + server.workbooks.update(sample_workbook) + + if args.download: + # Download + path = server.workbooks.download(sample_workbook.id, args.download) + 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("\nDownloaded preview image of workbook to {}".format(os.path.abspath(args.preview_image))) + -parser = argparse.ArgumentParser(description='Explore workbook 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('--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('--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) - -# SIGN IN -tableau_auth = TSC.TableauAuth(args.username, password) -server = TSC.Server(args.server) -with server.auth.sign_in(tableau_auth): - - # Publish workbook if publish flag is set (-publish, -p) - if args.publish: - all_projects, pagination_item = server.projects.get() - default_project = next((project for project in all_projects if project.is_default()), None) - - if default_project is not None: - new_workbook = TSC.WorkbookItem(default_project.id) - new_workbook = server.workbooks.publish(new_workbook, args.publish, TSC.Server.PublishMode.Overwrite) - 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("\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 - sample_workbook = all_workbooks[0] - - # Populate views - server.workbooks.populate_views(sample_workbook) - 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("\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 = copy.copy(sample_workbook.tags) - sample_workbook.tags.update('a', 'b', 'c', 'd') - sample_workbook.show_tabs = True - server.workbooks.update(sample_workbook) - print("\nOld tag set: {}".format(original_tag_set)) - print("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 - server.workbooks.update(sample_workbook) - - if args.download: - # Download - path = server.workbooks.download(sample_workbook.id, args.download) - 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("\nDownloaded preview image of workbook to {}".format(os.path.abspath(args.preview_image))) +if __name__ == '__main__': + main() diff --git a/samples/move_workbook_projects.py b/samples/move_workbook_projects.py index 3b553efb7..8bb1b4e50 100644 --- a/samples/move_workbook_projects.py +++ b/samples/move_workbook_projects.py @@ -7,50 +7,60 @@ # To run the script, you must have installed Python 2.7.X or 3.3 and later. #### -import tableauserverclient as TSC import argparse import getpass import logging -parser = argparse.ArgumentParser(description='Move one workbook from the default project to another.') -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('--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 -tableau_auth = TSC.TableauAuth(args.username, password) -server = TSC.Server(args.server) -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 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) - - 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)) +import tableauserverclient as TSC + + +def main(): + + parser = argparse.ArgumentParser(description='Move one workbook from the default project to another.') + 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('--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 + tableau_auth = TSC.TableauAuth(args.username, password) + server = TSC.Server(args.server) + + 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 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) + + 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 workbook named {} found.".format(args.workbook_name) + error = "No project named {} found.".format(args.destination_project) raise LookupError(error) - else: - error = "No project named {} found.".format(args.destination_project) - raise LookupError(error) + + +if __name__ == '__main__': + main() diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index 68904f3cf..b609ecffb 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -7,80 +7,90 @@ # To run the script, you must have installed Python 2.7.X or 3.3 and later. #### -import tableauserverclient as TSC -import shutil import argparse -import tempfile import getpass import logging +import shutil +import tempfile + +import tableauserverclient as TSC + + +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.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('--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: ") -parser = argparse.ArgumentParser(description="Move one workbook from the" - "default project of the default site to" - "the default project of another site.") -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('--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 both sites on server -tableau_auth = TSC.TableauAuth(args.username, password) - -source_server = TSC.Server(args.server) -dest_server = TSC.Server(args.server) - -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)) - 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)) - else: - tmpdir = tempfile.mkdtemp() - try: - 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() - 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) - - tableau_auth.site = args.destination_site - - # 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: 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." + # 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) + + source_server = TSC.Server(args.server) + dest_server = TSC.Server(args.server) + + 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)) + 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)) + else: + tmpdir = tempfile.mkdtemp() + try: + 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() + 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) - # Step 7: Delete workbook from source site and delete temp directory - source_server.workbooks.delete(all_workbooks[0].id) - finally: - shutil.rmtree(tmpdir) + tableau_auth.site = args.destination_site + + # 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: 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 + source_server.workbooks.delete(all_workbooks[0].id) + + finally: + shutil.rmtree(tmpdir) + + +if __name__ == '__main__': + main() diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 6a720fc35..37d66d2dc 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -14,39 +14,51 @@ # To run the script, you must have installed Python 2.7.X or 3.3 and later. #### -import tableauserverclient as TSC import argparse import getpass import logging -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('--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. -tableau_auth = TSC.TableauAuth(args.username, password) -server = TSC.Server(args.server) -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) - - # 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, TSC.Server.PublishMode.Overwrite) - print("Workbook published. ID: {0}".format(new_workbook.id)) - else: - error = "The default project could not be found." - raise LookupError(error) +import tableauserverclient as TSC + + +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('--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. + tableau_auth = TSC.TableauAuth(args.username, password) + server = TSC.Server(args.server) + + overwrite_true = TSC.Server.PublishMode.Overwrite + + 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) + + # 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)) + else: + error = "The default project could not be found." + raise LookupError(error) + + +if __name__ == '__main__': + main() diff --git a/samples/set_http_options.py b/samples/set_http_options.py index dcec24428..fb5ce2441 100644 --- a/samples/set_http_options.py +++ b/samples/set_http_options.py @@ -5,35 +5,44 @@ # To run the script, you must have installed Python 2.7.X or 3.3 and later. #### -import tableauserverclient as TSC import argparse import getpass import logging -parser = argparse.ArgumentParser(description='List workbooks on site, with option set to ignore SSL verification.') -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', - help='desired logging level (set to error by default)') -args = parser.parse_args() +import tableauserverclient as TSC + + +def main(): + + parser = argparse.ArgumentParser(description='List workbooks on site, with option set to ignore SSL verification.') + 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', + 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) -password = getpass.getpass("Password: ") + # Step 1: Create required objects for sign in + tableau_auth = TSC.TableauAuth(args.username, password) + server = TSC.Server(args.server) -# 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 2: Set http options to disable verifying SSL + server.add_http_options({'verify': False}) -# Step 1: Create required objects for sign in -tableau_auth = TSC.TableauAuth(args.username, password) -server = TSC.Server(args.server) + with server.auth.sign_in(tableau_auth): -# Step 2: Set http options to disable verifying SSL -server.add_http_options({'verify': False}) + # 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)) -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() From 5dbdbee13c54c0e66743e27a75ba0646e6dd85c3 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Fri, 7 Oct 2016 13:47:28 -0500 Subject: [PATCH 13/32] Adding Schedules Support (#48) Original PR by @shinchris #30 * added ability to query and delete schedules * added ability to create and update schedules * intervals for schedules are expressed as (Unit)Interval classes * hourly intervals can take .25 and .5 to represent 15 and 30 minute schedules --- samples/create_schedules.py | 77 ++++++ tableauserverclient/__init__.py | 5 +- tableauserverclient/models/__init__.py | 2 + tableauserverclient/models/interval_item.py | 183 ++++++++++++++ .../models/property_decorators.py | 38 +++ tableauserverclient/models/schedule_item.py | 235 ++++++++++++++++++ tableauserverclient/server/__init__.py | 6 +- .../server/endpoint/__init__.py | 1 + .../server/endpoint/schedules_endpoint.py | 60 +++++ tableauserverclient/server/request_factory.py | 53 ++++ tableauserverclient/server/server.py | 3 +- test/assets/schedule_create_daily.xml | 6 + test/assets/schedule_create_hourly.xml | 10 + test/assets/schedule_create_monthly.xml | 10 + test/assets/schedule_create_weekly.xml | 12 + test/assets/schedule_get.xml | 8 + test/assets/schedule_get_empty.xml | 5 + test/assets/schedule_update.xml | 11 + test/test_schedule.py | 183 ++++++++++++++ 19 files changed, 902 insertions(+), 6 deletions(-) create mode 100644 samples/create_schedules.py create mode 100644 tableauserverclient/models/interval_item.py create mode 100644 tableauserverclient/models/schedule_item.py create mode 100644 tableauserverclient/server/endpoint/schedules_endpoint.py create mode 100644 test/assets/schedule_create_daily.xml create mode 100644 test/assets/schedule_create_hourly.xml create mode 100644 test/assets/schedule_create_monthly.xml create mode 100644 test/assets/schedule_create_weekly.xml create mode 100644 test/assets/schedule_get.xml create mode 100644 test/assets/schedule_get_empty.xml create mode 100644 test/assets/schedule_update.xml create mode 100644 test/test_schedule.py diff --git a/samples/create_schedules.py b/samples/create_schedules.py new file mode 100644 index 000000000..c8d32b087 --- /dev/null +++ b/samples/create_schedules.py @@ -0,0 +1,77 @@ +#### +# 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. +#### + + +import argparse +import getpass +import logging + +from datetime import time + +import tableauserverclient as TSC + + +def main(): + + parser = argparse.ArgumentParser(description='Creates sample schedules for each type of frequency.') + 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', + 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) + + tableau_auth = TSC.TableauAuth(args.username, password) + server = TSC.Server(args.server) + 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)) + + # 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)) + + # 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)) + + # 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__': + main() diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 7ac613556..9e56919c6 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,7 +1,8 @@ from .namespace import NAMESPACE from .models import ConnectionItem, DatasourceItem,\ - GroupItem, PaginationItem, ProjectItem, \ - SiteItem, TableauAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError + GroupItem, PaginationItem, ProjectItem, ScheduleItem, \ + SiteItem, TableauAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError, \ + HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem from .server import RequestOptions, Filter, Sort, Server, ServerResponseError,\ MissingRequiredFieldError, NotSignedInError diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 594252f8f..276684d66 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -2,8 +2,10 @@ from .datasource_item import DatasourceItem from .exceptions import UnpopulatedPropertyError from .group_item import GroupItem +from .interval_item import IntervalItem, DailyInterval, WeeklyInterval, MonthlyInterval, HourlyInterval from .pagination_item import PaginationItem from .project_item import ProjectItem +from .schedule_item import ScheduleItem from .site_item import SiteItem from .tableau_auth import TableauAuth from .user_item import UserItem diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py new file mode 100644 index 000000000..484ee709f --- /dev/null +++ b/tableauserverclient/models/interval_item.py @@ -0,0 +1,183 @@ +from .property_decorators import property_is_valid_time, property_not_nullable + + +class IntervalItem(object): + class Frequency: + Hourly = "Hourly" + Daily = "Daily" + Weekly = "Weekly" + Monthly = "Monthly" + + class Occurrence: + Minutes = "minutes" + Hours = "hours" + WeekDay = "weekDay" + MonthDay = "monthDay" + + class Day: + Sunday = "Sunday" + Monday = "Monday" + Tuesday = "Tuesday" + Wednesday = "Wednesday" + Thursday = "Thursday" + Friday = "Friday" + Saturday = "Saturday" + LastDay = "LastDay" + + +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 + + @property + def _frequency(self): + return IntervalItem.Frequency.Hourly + + @property + def start_time(self): + return self._start_time + + @start_time.setter + @property_is_valid_time + @property_not_nullable + def start_time(self, value): + self._start_time = value + + @property + def end_time(self): + return self._end_time + + @end_time.setter + @property_is_valid_time + @property_not_nullable + def end_time(self, value): + self._end_time = value + + @property + def interval(self): + return self._interval + + @interval.setter + def interval(self, interval): + VALID_INTERVALS = {.25, .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) + + 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 {.25, .5}: + calculated_interval = int(self.interval * 60) + interval_type = IntervalItem.Occurrence.Minutes + else: + calculated_interval = self.interval + interval_type = IntervalItem.Occurrence.Hours + + return [(interval_type, str(calculated_interval))] + + +class DailyInterval(object): + def __init__(self, start_time): + self.start_time = start_time + + @property + def _frequency(self): + return IntervalItem.Frequency.Daily + + @property + def start_time(self): + return self._start_time + + @start_time.setter + @property_is_valid_time + @property_not_nullable + def start_time(self, value): + self._start_time = value + + +class WeeklyInterval(object): + def __init__(self, start_time, *interval_values): + self.start_time = start_time + self.interval = interval_values + + @property + def _frequency(self): + return IntervalItem.Frequency.Weekly + + @property + def start_time(self): + return self._start_time + + @start_time.setter + @property_is_valid_time + @property_not_nullable + def start_time(self, value): + self._start_time = value + + @property + def interval(self): + return self._interval + + @interval.setter + def interval(self, interval_values): + if not all(hasattr(IntervalItem.Day, day) for day in interval_values): + raise ValueError("Invalid week day defined " + str(interval_values)) + + self._interval = interval_values + + def _interval_type_pairs(self): + return [(IntervalItem.Occurrence.WeekDay, day) for day in self.interval] + + +class MonthlyInterval(object): + def __init__(self, start_time, interval_value): + self.start_time = start_time + self.interval = str(interval_value) + + @property + def _frequency(self): + return IntervalItem.Frequency.Monthly + + @property + def start_time(self): + return self._start_time + + @start_time.setter + @property_is_valid_time + @property_not_nullable + def start_time(self, value): + self._start_time = value + + @property + def interval(self): + return self._interval + + @interval.setter + def interval(self, interval_value): + error = "Invalid interval value for a monthly frequency: {}.".format(interval_value) + + # 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 + + if interval_value != "LastDay": + try: + if not (1 <= int(interval_value) <= 31): + raise ValueError(error) + except ValueError as e: + if interval_value != "LastDay": + raise ValueError(error) + + self._interval = str(interval_value) + + def _interval_type_pairs(self): + return [(IntervalItem.Occurrence.MonthDay, self.interval)] diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 5a1b7560e..1b3b7ccaa 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -46,3 +46,41 @@ def wrapper(self, value): return func(self, value) return wrapper + + +def property_is_valid_time(func): + @wraps(func) + def wrapper(self, value): + units_of_time = {"hour", "minute", "second"} + + if not any(hasattr(value, unit) for unit in units_of_time): + error = "Invalid time object defined." + raise ValueError(error) + return func(self, value) + + return wrapper + + +def property_is_int(range): + def property_type_decorator(func): + @wraps(func) + def wrapper(self, value): + error = "Invalid priority defined: {}.".format(value) + + if range is None: + if isinstance(value, int): + return func(self, value) + else: + raise ValueError(error) + + min, max = range + + if value < min or value > max: + + raise ValueError(error) + + return func(self, value) + + return wrapper + + return property_type_decorator diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py new file mode 100644 index 000000000..3d477692a --- /dev/null +++ b/tableauserverclient/models/schedule_item.py @@ -0,0 +1,235 @@ +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 .. import NAMESPACE + + +class ScheduleItem(object): + class Type: + Extract = "Extract" + Subscription = "Subscription" + + class ExecutionOrder: + Parallel = "Parallel" + Serial = "Serial" + + 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._execution_order = None + self._id = None + self._name = None + self._next_run_at = None + self._priority = None + self._schedule_type = None + self._state = None + self._updated_at = None + self.interval_item = interval_item + + # Invoke setter + self.execution_order = execution_order + self.name = name + self.priority = priority + self.schedule_type = schedule_type + + @property + def created_at(self): + return self._created_at + + @property + def end_schedule_at(self): + return self._end_schedule_at + + @property + def execution_order(self): + return self._execution_order + + @execution_order.setter + @property_is_enum(ExecutionOrder) + def execution_order(self, value): + self._execution_order = value + + @property + def id(self): + return self._id + + @property + def name(self): + return self._name + + @name.setter + @property_not_nullable + def name(self, value): + self._name = value + + @property + def next_run_at(self): + return self._next_run_at + + @property + def priority(self): + return self._priority + + @priority.setter + @property_is_int(range=(1, 100)) + def priority(self, value): + self._priority = value + + @property + def schedule_type(self): + return self._schedule_type + + @schedule_type.setter + @property_is_enum(Type) + @property_not_nullable + def schedule_type(self, value): + self._schedule_type = value + + @property + def state(self): + return self._state + + @state.setter + @property_is_enum(State) + def state(self, value): + self._state = value + + @property + def updated_at(self): + return self._updated_at + + def _parse_common_tags(self, schedule_xml): + if not isinstance(schedule_xml, ET.Element): + schedule_xml = ET.fromstring(schedule_xml).find('.//t:schedule', namespaces=NAMESPACE) + 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) + + 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): + if id is not None: + self._id = id + if name: + self._name = name + if state: + self._state = state + if created_at: + self._created_at = created_at + if updated_at: + self._updated_at = updated_at + if schedule_type: + self._schedule_type = schedule_type + if next_run_at: + self._next_run_at = next_run_at + if end_schedule_at: + self._end_schedule_at = end_schedule_at + if execution_order: + self._execution_order = execution_order + if priority: + self._priority = priority + if interval_item: + self._interval_item = interval_item + + @classmethod + def from_response(cls, resp): + all_schedule_items = [] + parsed_response = ET.fromstring(resp) + all_schedule_xml = parsed_response.findall('.//t:schedule', namespaces=NAMESPACE) + 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) + + 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) + + all_schedule_items.append(schedule_item) + return all_schedule_items + + @staticmethod + def _parse_interval_item(parsed_response, frequency): + start_time = parsed_response.get("start", None) + start_time = datetime.strptime(start_time, "%H:%M:%S").time() + end_time = parsed_response.get("end", None) + if end_time is not None: + end_time = datetime.strptime(end_time, "%H:%M:%S").time() + interval_elems = parsed_response.findall(".//t:intervals/t:interval", namespaces=NAMESPACE) + interval = [] + for interval_elem in interval_elems: + interval.extend(interval_elem.attrib.items()) + + if frequency == IntervalItem.Frequency.Daily: + return DailyInterval(start_time) + + if frequency == IntervalItem.Frequency.Hourly: + interval_occurrence, interval_value = interval.pop() + + # 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) + + return HourlyInterval(start_time, end_time, interval_value) + + 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) + + @staticmethod + def _parse_element(schedule_xml): + id = schedule_xml.get('id', None) + name = schedule_xml.get('name', None) + state = schedule_xml.get('state', None) + created_at = schedule_xml.get('createdAt', None) + updated_at = schedule_xml.get('updatedAt', None) + schedule_type = schedule_xml.get('type', None) + frequency = schedule_xml.get('frequency', None) + next_run_at = schedule_xml.get('nextRunAt', None) + end_schedule_at = 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=NAMESPACE) + if frequency_detail_elem is not None: + interval_item = ScheduleItem._parse_interval_item(frequency_detail_elem, frequency) + + return id, name, state, created_at, updated_at, schedule_type, \ + next_run_at, end_schedule_at, execution_order, priority, interval_item diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 909705052..a8b78b7fc 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -3,9 +3,9 @@ from .filter import Filter from .sort import Sort from .. import ConnectionItem, DatasourceItem,\ - GroupItem, PaginationItem, ProjectItem, SiteItem, TableauAuth,\ + GroupItem, PaginationItem, ProjectItem, ScheduleItem, SiteItem, TableauAuth,\ UserItem, ViewItem, WorkbookItem, NAMESPACE -from .endpoint import Auth, Datasources, Endpoint, \ - Groups, Projects, Sites, Users, Views, Workbooks, ServerResponseError, MissingRequiredFieldError +from .endpoint import Auth, Datasources, Endpoint, Groups, Projects, Schedules, \ + Sites, Users, Views, Workbooks, ServerResponseError, MissingRequiredFieldError from .server import Server from .exceptions import NotSignedInError diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 30adf2549..65e15c683 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -4,6 +4,7 @@ from .exceptions import ServerResponseError, MissingRequiredFieldError from .groups_endpoint import Groups from .projects_endpoint import Projects +from .schedules_endpoint import Schedules from .sites_endpoint import Sites from .users_endpoint import Users from .views_endpoint import Views diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py new file mode 100644 index 000000000..9b4721941 --- /dev/null +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -0,0 +1,60 @@ +from .endpoint import Endpoint +from .exceptions import MissingRequiredFieldError +from .. import RequestFactory, PaginationItem, ScheduleItem +import logging +import copy + +logger = logging.getLogger('tableau.endpoint.schedules') + + +class Schedules(Endpoint): + def __init__(self, parent_srv): + super(Endpoint, self).__init__() + self.parent_srv = parent_srv + + @property + def baseurl(self): + return "{0}/schedules".format(self.parent_srv.baseurl) + + def get(self, req_options=None): + logger.info("Querying all schedules") + url = self.baseurl + server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content) + all_schedule_items = ScheduleItem.from_response(server_response.content) + return all_schedule_items, pagination_item + + def delete(self, schedule_id): + if not schedule_id: + error = "Schedule ID undefined" + raise ValueError(error) + url = "{0}/{1}".format(self.baseurl, schedule_id) + self.delete_request(url) + logger.info("Deleted single schedule (ID: {0})".format(schedule_id)) + + 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) + server_response = self.put_request(url, update_req) + 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) + + def create(self, schedule_item): + if schedule_item.interval_item is None: + error = "Interval item must be defined." + raise MissingRequiredFieldError(error) + + url = self.baseurl + create_req = RequestFactory.Schedule.create_req(schedule_item) + server_response = self.post_request(url, create_req) + new_schedule = ScheduleItem.from_response(server_response.content)[0] + logger.info("Created new schedule (ID: {})".format(new_schedule.id)) + return new_schedule diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 3439cfa43..602efb92b 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -129,6 +129,58 @@ def create_req(self, project_item): 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 + 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'): + if interval_item.end_time: + frequency_element.attrib['end'] = str(interval_item.end_time) + if hasattr(interval_item, 'interval'): + if 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 + return ET.tostring(xml_request) + + def update_req(self, schedule_item): + xml_request = ET.Element('tsRequest') + schedule_element = ET.SubElement(xml_request, 'schedule') + if schedule_item.name: + schedule_element.attrib['name'] = schedule_item.name + if schedule_item.priority: + schedule_element.attrib['priority'] = str(schedule_item.priority) + if schedule_item.execution_order: + 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'): + if interval_item.end_time: + 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) + + class SiteRequest(object): def update_req(self, site_item): xml_request = ET.Element('tsRequest') @@ -249,6 +301,7 @@ class RequestFactory(object): Group = GroupRequest() Permission = PermissionRequest() Project = ProjectRequest() + Schedule = ScheduleRequest() Site = SiteRequest() Tag = TagRequest() User = UserRequest() diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 3458e0644..24d56e1fe 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,5 +1,5 @@ from .exceptions import NotSignedInError -from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth +from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, Schedules import requests @@ -26,6 +26,7 @@ def __init__(self, server_address): self.workbooks = Workbooks(self) self.datasources = Datasources(self) self.projects = Projects(self) + self.schedules = Schedules(self) def add_http_options(self, options_dict): self._http_options.update(options_dict) diff --git a/test/assets/schedule_create_daily.xml b/test/assets/schedule_create_daily.xml new file mode 100644 index 000000000..fe1eda485 --- /dev/null +++ b/test/assets/schedule_create_daily.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/test/assets/schedule_create_hourly.xml b/test/assets/schedule_create_hourly.xml new file mode 100644 index 000000000..b1c3b73c3 --- /dev/null +++ b/test/assets/schedule_create_hourly.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/schedule_create_monthly.xml b/test/assets/schedule_create_monthly.xml new file mode 100644 index 000000000..408ff428d --- /dev/null +++ b/test/assets/schedule_create_monthly.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/schedule_create_weekly.xml b/test/assets/schedule_create_weekly.xml new file mode 100644 index 000000000..624a56e25 --- /dev/null +++ b/test/assets/schedule_create_weekly.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/schedule_get.xml b/test/assets/schedule_get.xml new file mode 100644 index 000000000..3d8578ede --- /dev/null +++ b/test/assets/schedule_get.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/test/assets/schedule_get_empty.xml b/test/assets/schedule_get_empty.xml new file mode 100644 index 000000000..c40943303 --- /dev/null +++ b/test/assets/schedule_get_empty.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/test/assets/schedule_update.xml b/test/assets/schedule_update.xml new file mode 100644 index 000000000..314925377 --- /dev/null +++ b/test/assets/schedule_update.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_schedule.py b/test/test_schedule.py new file mode 100644 index 000000000..710bfe2a2 --- /dev/null +++ b/test/test_schedule.py @@ -0,0 +1,183 @@ +import unittest +import os +import requests_mock +import tableauserverclient as TSC +from datetime import time + +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") + +GET_XML = os.path.join(TEST_ASSET_DIR, "schedule_get.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") +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") + + +class ScheduleTests(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.baseurl = self.server.schedules.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_schedules, pagination_item = self.server.schedules.get() + + 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", all_schedules[0].created_at) + self.assertEqual("2016-09-13T11:00:32Z", all_schedules[0].updated_at) + self.assertEqual("Extract", all_schedules[0].schedule_type) + self.assertEqual("2016-09-14T11:00:00Z", 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", all_schedules[1].created_at) + self.assertEqual("2016-09-12T16:39:38Z", all_schedules[1].updated_at) + self.assertEqual("Subscription", all_schedules[1].schedule_type) + self.assertEqual("2016-09-18T06:00:00Z", all_schedules[1].next_run_at) + + def test_get_empty(self): + 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_schedules, pagination_item = self.server.schedules.get() + + self.assertEqual(0, pagination_item.total_available) + self.assertEqual([], all_schedules) + + def test_delete(self): + 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): + 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) + new_schedule = self.server.schedules.create(new_schedule) + + self.assertEqual("5f42be25-8a43-47ba-971a-63f2d4e7029c", new_schedule.id) + self.assertEqual("hourly-schedule-1", new_schedule.name) + self.assertEqual("Active", new_schedule.state) + self.assertEqual(50, new_schedule.priority) + self.assertEqual("2016-09-15T20:47:33Z", new_schedule.created_at) + self.assertEqual("2016-09-15T20:47:33Z", new_schedule.updated_at) + self.assertEqual(TSC.ScheduleItem.Type.Extract, new_schedule.schedule_type) + self.assertEqual("2016-09-16T01:30:00Z", 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) + + def test_create_daily(self): + 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 = self.server.schedules.create(new_schedule) + + self.assertEqual("907cae38-72fd-417c-892a-95540c4664cd", new_schedule.id) + self.assertEqual("daily-schedule-1", new_schedule.name) + self.assertEqual("Active", new_schedule.state) + self.assertEqual(90, new_schedule.priority) + self.assertEqual("2016-09-15T21:01:09Z", new_schedule.created_at) + self.assertEqual("2016-09-15T21:01:09Z", new_schedule.updated_at) + self.assertEqual(TSC.ScheduleItem.Type.Subscription, new_schedule.schedule_type) + self.assertEqual("2016-09-16T11:45:00Z", new_schedule.next_run_at) + 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): + 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) + new_schedule = self.server.schedules.create(new_schedule) + + self.assertEqual("1adff386-6be0-4958-9f81-a35e676932bf", new_schedule.id) + self.assertEqual("weekly-schedule-1", new_schedule.name) + self.assertEqual("Active", new_schedule.state) + self.assertEqual(80, new_schedule.priority) + self.assertEqual("2016-09-15T21:12:50Z", new_schedule.created_at) + self.assertEqual("2016-09-15T21:12:50Z", new_schedule.updated_at) + self.assertEqual(TSC.ScheduleItem.Type.Extract, new_schedule.schedule_type) + self.assertEqual("2016-09-16T16:15:00Z", 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) + + def test_create_monthly(self): + 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 = self.server.schedules.create(new_schedule) + + self.assertEqual("e06a7c75-5576-4f68-882d-8909d0219326", new_schedule.id) + self.assertEqual("monthly-schedule-1", new_schedule.name) + self.assertEqual("Active", new_schedule.state) + self.assertEqual(20, new_schedule.priority) + self.assertEqual("2016-09-15T21:16:56Z", new_schedule.created_at) + self.assertEqual("2016-09-15T21:16:56Z", new_schedule.updated_at) + self.assertEqual(TSC.ScheduleItem.Type.Extract, new_schedule.schedule_type) + self.assertEqual("2016-10-12T14:00:00Z", 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) + + 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 + '/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 = self.server.schedules.update(single_schedule) + + self.assertEqual("7bea1766-1543-4052-9753-9d224bc069b5", single_schedule.id) + self.assertEqual("weekly-schedule-1", single_schedule.name) + self.assertEqual(90, single_schedule.priority) + self.assertEqual("2016-09-15T23:50:02Z", single_schedule.updated_at) + self.assertEqual(TSC.ScheduleItem.Type.Extract, single_schedule.schedule_type) + self.assertEqual("2016-09-16T14:00:00Z", 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) From 6da2cbebe09325dafa70fccf3632a37b1d8d4261 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Tue, 11 Oct 2016 14:59:48 -0500 Subject: [PATCH 14/32] Minor cleanups in __init__ and ScheduleItem serializer, and a flake8 cleanup (#57) --- tableauserverclient/models/datasource_item.py | 3 --- tableauserverclient/models/group_item.py | 3 --- tableauserverclient/models/project_item.py | 9 +------- tableauserverclient/models/schedule_item.py | 6 ----- tableauserverclient/models/site_item.py | 13 +---------- tableauserverclient/models/user_item.py | 10 +------- tableauserverclient/models/workbook_item.py | 4 ---- .../server/endpoint/exceptions.py | 8 +++---- tableauserverclient/server/request_factory.py | 23 ++++++++----------- 9 files changed, 16 insertions(+), 63 deletions(-) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 68b4e5a87..3ae4c5743 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -11,15 +11,12 @@ def __init__(self, project_id, name=None): self._content_url = None self._created_at = None self._id = None - self._project_id = None self._project_name = None self._tags = set() self._datasource_type = None self._updated_at = None self.name = name self.owner_id = None - - # Invoke setter self.project_id = project_id @property diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index e4be03b78..c0014eac0 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -8,10 +8,7 @@ class GroupItem(object): def __init__(self, name): self._domain_name = None self._id = None - self._name = None self._users = None - - # Invoke setter self.name = name @property diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 70585b479..b60a62633 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -11,16 +11,9 @@ class ContentPermissions: def __init__(self, name, description=None, content_permissions=None): self._content_permissions = None self._id = None - self._name = None self.description = description - - # Invoke setter self.name = name - - if content_permissions: - # In order to invoke the setter method to validate content_permissions, - # _content_permissions must be initialized first. - self.content_permissions = content_permissions + self.content_permissions = content_permissions @property def content_permissions(self): diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index 3d477692a..b0f7d1edb 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -22,17 +22,11 @@ class State: def __init__(self, name, priority, schedule_type, execution_order, interval_item): self._created_at = None self._end_schedule_at = None - self._execution_order = None self._id = None - self._name = None self._next_run_at = None - self._priority = None - self._schedule_type = None self._state = None self._updated_at = None self.interval_item = interval_item - - # Invoke setter self.execution_order = execution_order self.name = name self.priority = priority diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 9d742e8fc..283eca192 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -15,31 +15,20 @@ 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): self._admin_mode = None - self._content_url = None - self._disable_subscriptions = None self._id = None - self._name = None self._num_users = None - self._revision_history_enabled = None self._state = None self._status_reason = None self._storage = None - self._subscribe_others_enabled = None self.revision_limit = None self.user_quota = user_quota self.storage_quota = storage_quota - - # Invoke setter self.content_url = content_url self.disable_subscriptions = disable_subscriptions self.name = name self.revision_history_enabled = revision_history_enabled self.subscribe_others_enabled = subscribe_others_enabled - - if admin_mode: - # In order to invoke the setter method to validate admin_mode, - # _admin_mode must be initialized first. - self.admin_mode = admin_mode + self.admin_mode = admin_mode @property def admin_mode(self): diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 16c5d3b72..5cacd6221 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -25,21 +25,13 @@ def __init__(self, name, site_role, auth_setting=None): self._external_auth_user_id = None self._id = None self._last_login = None - self._name = None - self._site_role = None self._workbooks = None self.email = None self.fullname = None self.password = None - - # Invoke setter self.name = name self.site_role = site_role - - if auth_setting: - # In order to invoke the setter method for auth_setting, - # _auth_setting must be initialized first - self.auth_setting = auth_setting + self.auth_setting = auth_setting @property def auth_setting(self): diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 133959d5d..9ccde5606 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -15,17 +15,13 @@ def __init__(self, project_id, name=None, show_tabs=False): self._id = None self._initial_tags = set() self._preview_image = None - self._project_id = None self._project_name = None - self._show_tabs = None self._size = None self._updated_at = None self._views = None self.name = name self.owner_id = None self.tags = set() - - # Invoke setter self.project_id = project_id self.show_tabs = show_tabs diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 4efac7bf5..7907a6dab 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -16,11 +16,9 @@ def __str__(self): def from_response(cls, resp): # Check elements exist before .text parsed_response = ET.fromstring(resp) - error_response = cls( - parsed_response.find('t:error', namespaces=NAMESPACE).get('code', ''), - parsed_response.find('.//t:summary', namespaces=NAMESPACE).text, - parsed_response.find('.//t:detail', namespaces=NAMESPACE).text - ) + error_response = cls(parsed_response.find('t:error', namespaces=NAMESPACE).get('code', ''), + parsed_response.find('.//t:summary', namespaces=NAMESPACE).text, + parsed_response.find('.//t:detail', namespaces=NAMESPACE).text) return error_response diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 602efb92b..3bc513c38 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -141,16 +141,14 @@ 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'): - if interval_item.end_time: - frequency_element.attrib['end'] = str(interval_item.end_time) - if hasattr(interval_item, 'interval'): - if 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 + if hasattr(interval_item, 'end_time') and interval_item.end_time: + 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 return ET.tostring(xml_request) def update_req(self, schedule_item): @@ -169,9 +167,8 @@ 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'): - if interval_item.end_time: - frequency_element.attrib['end'] = str(interval_item.end_time) + if hasattr(interval_item, 'end_time') and interval_item.end_time: + 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(): From 03d26cdf8772da746fc41cd441d33deb76b38554 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Sat, 15 Oct 2016 13:57:41 -0500 Subject: [PATCH 15/32] Fix for issue #60 (#61) * Fix for issie #60. * Make Sign Out a no-op when you are already signed out and expose an is_signed_in method --- tableauserverclient/server/endpoint/auth_endpoint.py | 3 +++ tableauserverclient/server/endpoint/sites_endpoint.py | 7 ++++++- tableauserverclient/server/server.py | 3 +++ test/test_site.py | 2 +- 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index e685effbe..9e316d042 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -41,6 +41,9 @@ def sign_in(self, auth_req): def sign_out(self): 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 self.post_request(url, '') self.parent_srv._clear_auth() logger.info('Signed out') diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 704fb9de9..51736ffc7 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -59,7 +59,12 @@ def delete(self, site_id): raise ValueError(error) url = "{0}/{1}".format(self.baseurl, site_id) self.delete_request(url) - logger.info('Deleted single site (ID: {0})'.format(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') + self.parent_srv._clear_auth() + logger.info('Deleted single site (ID: {0}) and signed out'.format(site_id)) # Create new site def create(self, site_item): diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 24d56e1fe..8c28c1825 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -81,3 +81,6 @@ def http_options(self): @property def session(self): return self._session + + def is_signed_in(self): + return self._auth_token is not None diff --git a/test/test_site.py b/test/test_site.py index 3076e4ce3..311f9524f 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -17,7 +17,7 @@ def setUp(self): # Fake signin self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' - + self.server._site_id = '0626857c-1def-4503-a7d8-7907c3ff9d9f' self.baseurl = self.server.sites.baseurl def test_get(self): From 4f4112b4f4aac49a9e906b10c8c5870d095c8468 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Tue, 18 Oct 2016 16:18:01 -0500 Subject: [PATCH 16/32] Fix for issue #50 Add regex validator to content_url (#64) Content_urls must contain only a subset of ascii characters (alphanumeric, -, _) and will fail on site creation otherwise. Server sends an error but it can be confusing to the user. Fix is to validate it in the library so we never send an invalid content_url * Added a property decorator that validates the content_url against a supplied regex * Updated tests to test new cases (with unicode!) and added a new test for 'valid' cases to exercise regex --- .../models/property_decorators.py | 15 +++++++++++++++ tableauserverclient/models/site_item.py | 7 ++++++- test/test_site_model.py | 19 ++++++++++++++++--- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 1b3b7ccaa..de8fe8d8c 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,3 +1,4 @@ +import re from functools import wraps @@ -84,3 +85,17 @@ def wrapper(self, value): return wrapper return property_type_decorator + + +def property_matches(regex_to_match, error): + + compiled_re = re.compile(regex_to_match) + + def wrapper(func): + @wraps(func) + 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 diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 283eca192..40a49e453 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -1,8 +1,12 @@ import xml.etree.ElementTree as ET -from .property_decorators import property_is_enum, property_is_boolean, property_not_empty, property_not_nullable +from .property_decorators import (property_is_enum, property_is_boolean, property_matches, + property_not_empty, property_not_nullable) from .. import NAMESPACE +VALID_CONTENT_URL_RE = r"^[a-zA-Z0-9_\-]*$" + + class SiteItem(object): class AdminMode: ContentAndUsers = 'ContentAndUsers' @@ -45,6 +49,7 @@ 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") def content_url(self, value): self._content_url = value diff --git a/test/test_site_model.py b/test/test_site_model.py index 02735ed8c..99fa73ce9 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 @@ -19,10 +21,21 @@ def test_invalid_admin_mode(self): site.admin_mode = "Hello" def test_invalid_content_url(self): - self.assertRaises(ValueError, TSC.SiteItem, "site", None) - site = TSC.SiteItem("site", "url") + with self.assertRaises(ValueError): - site.content_url = None + site = TSC.SiteItem(name="蚵仔煎", content_url="蚵仔煎") + + with self.assertRaises(ValueError): + site = TSC.SiteItem(name="蚵仔煎", content_url=None) + + def test_set_valid_content_url(self): + # Default Site + site = TSC.SiteItem(name="Default", content_url="") + self.assertEqual(site.content_url, "") + + # Unicode Name and ascii content_url + site = TSC.SiteItem(name="蚵仔煎", content_url="omlette") + self.assertEqual(site.content_url, "omlette") def test_invalid_disable_subscriptions(self): site = TSC.SiteItem("site", "url") From 02559aba26e8c5868e0d3c394bbeeb17b2c83fa1 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Thu, 20 Oct 2016 14:14:29 -0700 Subject: [PATCH 17/32] Making the user list operations a no-op if we haven't populated the list of users yet (#68) --- setup.py | 2 +- .../server/endpoint/groups_endpoint.py | 41 +++++++++++++------ test/test_group.py | 27 +++++++++--- 3 files changed, 50 insertions(+), 20 deletions(-) diff --git a/setup.py b/setup.py index c4b654ebf..7487acdf3 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name='tableauserverclient', - version='0.1', + version='0.2.dev0', author='Tableau', author_email='github@tableau.com', url='https://github.com/tableau/server-client-python', diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index d982ff13a..a5bb3eb24 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -1,5 +1,6 @@ from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError +from ...models.exceptions import UnpopulatedPropertyError from .. import RequestFactory, GroupItem, UserItem, PaginationItem import logging @@ -47,7 +48,31 @@ def delete(self, group_id): # Removes 1 user from 1 group def remove_user(self, group_item, user_id): - user_set = group_item.users + self._remove_user(group_item, user_id) + try: + user_set = group_item.users + for user in user_set: + if user.id == user_id: + user_set.remove(user) + break + except UnpopulatedPropertyError: + # If we aren't populated, do nothing to the user list + pass + logger.info('Removed user (id: {0}) from group (ID: {1})'.format(user_id, group_item.id)) + + # Adds 1 user to 1 group + def add_user(self, group_item, user_id): + new_user = self._add_user(group_item, user_id) + try: + user_set = group_item.users + user_set.add(new_user) + group_item._set_users(user_set) + except UnpopulatedPropertyError: + # If we aren't populated, do nothing to the user list + pass + logger.info('Added user (id: {0}) to group (ID: {1})'.format(user_id, group_item.id)) + + def _remove_user(self, group_item, user_id): if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) @@ -56,15 +81,8 @@ 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) - for user in user_set: - if user.id == user_id: - user_set.remove(user) - break - logger.info('Removed user (id: {0}) from group (ID: {1})'.format(user_id, group_item.id)) - # Adds 1 user to 1 group - def add_user(self, group_item, user_id): - user_set = group_item.users + def _add_user(self, group_item, user_id): if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) @@ -74,7 +92,4 @@ 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) - new_user = UserItem.from_response(server_response.content).pop() - user_set.add(new_user) - group_item._set_users(user_set) - logger.info('Added user (id: {0}) to group (ID: {1})'.format(user_id, group_item.id)) + return UserItem.from_response(server_response.content).pop() diff --git a/test/test_group.py b/test/test_group.py index a521c0d9a..5424bb53d 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -98,9 +98,17 @@ def test_add_user(self): self.assertEqual('ServerAdministrator', user.site_role) def test_add_user_before_populating(self): - single_group = TSC.GroupItem('test') - self.assertRaises(TSC.UnpopulatedPropertyError, self.server.groups.add_user, single_group, - '5de011f8-5aa9-4d5b-b991-f462c8dd6bb7') + 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) + 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') def test_add_user_missing_user_id(self): with open(POPULATE_USERS, 'rb') as f: @@ -120,9 +128,16 @@ def test_add_user_missing_group_id(self): '5de011f8-5aa9-4d5b-b991-f462c8dd6bb7') def test_remove_user_before_populating(self): - single_group = TSC.GroupItem('test') - self.assertRaises(TSC.UnpopulatedPropertyError, self.server.groups.remove_user, single_group, - '5de011f8-5aa9-4d5b-b991-f462c8dd6bb7') + 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') + 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') def test_remove_user_missing_user_id(self): with open(POPULATE_USERS, 'rb') as f: From eb91b41b71acf808316dce68eb40eed0ce343d16 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Thu, 20 Oct 2016 14:26:30 -0700 Subject: [PATCH 18/32] Bugfix 66 add create group basic (#69) --- samples/create_group.py | 42 +++++++++++++++++++ .../server/endpoint/groups_endpoint.py | 6 +++ tableauserverclient/server/request_factory.py | 6 +++ test/assets/group_create.xml | 6 +++ test/assets/group_create_async.xml | 7 ++++ test/test_group.py | 13 ++++++ 6 files changed, 80 insertions(+) create mode 100644 samples/create_group.py create mode 100644 test/assets/group_create.xml create mode 100644 test/assets/group_create_async.xml diff --git a/samples/create_group.py b/samples/create_group.py new file mode 100644 index 000000000..3b7892fdf --- /dev/null +++ b/samples/create_group.py @@ -0,0 +1,42 @@ +#### +# 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. +#### + + +import argparse +import getpass +import logging + +from datetime import time + +import tableauserverclient as TSC + + +def main(): + + parser = argparse.ArgumentParser(description='Creates sample schedules for each type of frequency.') + 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', + 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) + + tableau_auth = TSC.TableauAuth(args.username, password) + server = TSC.Server(args.server) + with server.auth.sign_in(tableau_auth): + group = TSC.GroupItem('test') + group = server.groups.create(group) + print(group) + + +if __name__ == '__main__': + main() diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index a5bb3eb24..a21888695 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -46,6 +46,12 @@ def delete(self, group_id): self.delete_request(url) logger.info('Deleted single group (ID: {0})'.format(group_id)) + def create(self, group_item): + url = self.baseurl + create_req = RequestFactory.Group.create_req(group_item) + server_response = self.post_request(url, create_req) + return GroupItem.from_response(server_response.content)[0] + # Removes 1 user from 1 group def remove_user(self, group_item, user_id): self._remove_user(group_item, user_id) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 3bc513c38..536971fc5 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -77,6 +77,12 @@ def add_user_req(self, user_id): user_element.attrib['id'] = user_id return ET.tostring(xml_request) + def create_req(self, group_item): + xml_request = ET.Element('tsRequest') + group_element = ET.SubElement(xml_request, 'group') + group_element.attrib['name'] = group_item.name + return ET.tostring(xml_request) + class PermissionRequest(object): def _add_capability(self, parent_element, capability_set, mode): diff --git a/test/assets/group_create.xml b/test/assets/group_create.xml new file mode 100644 index 000000000..8fb3902a4 --- /dev/null +++ b/test/assets/group_create.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/test/assets/group_create_async.xml b/test/assets/group_create_async.xml new file mode 100644 index 000000000..8c7ac1c22 --- /dev/null +++ b/test/assets/group_create_async.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/test/test_group.py b/test/test_group.py index 5424bb53d..ff928bf17 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -1,3 +1,4 @@ +# encoding=utf-8 import unittest import os import requests_mock @@ -8,6 +9,8 @@ GET_XML = os.path.join(TEST_ASSET_DIR, 'group_get.xml') POPULATE_USERS = os.path.join(TEST_ASSET_DIR, 'group_populate_users.xml') ADD_USER = os.path.join(TEST_ASSET_DIR, 'group_add_user.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') class GroupTests(unittest.TestCase): @@ -155,3 +158,13 @@ def test_remove_user_missing_group_id(self): single_group._users = [] 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') + with requests_mock.mock() as m: + m.post(self.baseurl, text=response_xml) + group_to_create = TSC.GroupItem(u'試供品') + group = self.server.groups.create(group_to_create) + self.assertEqual(group.name, u'試供品') + self.assertEqual(group.id, '3e4a9ea0-a07a-4fe6-b50f-c345c8c81034') From fd59034522e8aeb6a4fadc2d104d1adea806d186 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Tue, 25 Oct 2016 09:44:18 -0700 Subject: [PATCH 19/32] Adding contributing doc that was missing from the repo (#74) --- contributing.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 contributing.md diff --git a/contributing.md b/contributing.md new file mode 100644 index 000000000..b1eda5b55 --- /dev/null +++ b/contributing.md @@ -0,0 +1,55 @@ +# Contributing + +We welcome contributions to this project! + +Contribution can include, but are not limited to, any of the following: + +* File an Issue +* Request a Feature +* Implement a Requested Feature +* 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. + +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.** + +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 [github documentation](https://help.github.com/articles/creating-a-pull-request/) + +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. From 5ac17ec7532523e69d9c8700be13ef0cd24e50c6 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Tue, 25 Oct 2016 17:25:41 -0500 Subject: [PATCH 20/32] Pipe password through to Update User (#75) Update User supports updating a password but we were never passing it through to the serializer. We don't want to keep the password around in the model (and it's never returned from Server) so let's just add it to the update function. * add `password=None` to `users.update()` * remove `UserItem.password` and all associated tests -- it doesn't do anything --- tableauserverclient/models/user_item.py | 1 - tableauserverclient/server/endpoint/users_endpoint.py | 4 ++-- tableauserverclient/server/request_factory.py | 2 +- test/test_user.py | 1 - 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 5cacd6221..80cac09ce 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -28,7 +28,6 @@ def __init__(self, name, site_role, auth_setting=None): self._workbooks = None self.email = None self.fullname = None - self.password = None self.name = name self.site_role = site_role self.auth_setting = auth_setting diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index ba5238d19..1bc32d50e 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -36,13 +36,13 @@ def get_by_id(self, user_id): return UserItem.from_response(server_response.content).pop() # Update user - def update(self, user_item): + def update(self, user_item, password=None): if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) url = "{0}/{1}".format(self.baseurl, user_item.id) - update_req = RequestFactory.User.update_req(user_item) + 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)) updated_item = copy.copy(user_item) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 536971fc5..8f20cfa36 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -233,7 +233,7 @@ def add_req(self, tag_set): class UserRequest(object): - def update_req(self, user_item, password=''): + def update_req(self, user_item, password): xml_request = ET.Element('tsRequest') user_element = ET.SubElement(xml_request, 'user') if user_item.fullname: diff --git a/test/test_user.py b/test/test_user.py index ae0357cbe..71ec30207 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -87,7 +87,6 @@ def test_update(self): single_user.name = 'Cassie' single_user.fullname = 'Cassie' single_user.email = 'cassie@email.com' - single_user.password = 'password' single_user = self.server.users.update(single_user) self.assertEqual('Cassie', single_user.name) From e1f38a8a308ca36f26a21ce450f4a35d1dacfa22 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Thu, 27 Oct 2016 10:15:54 -0700 Subject: [PATCH 21/32] Update TableauAuth to speak in site_id rather than just site (#70) * Updating TableauAuth to speak in site_id rather than just site * EOL@EOF * updating to remove deprecated usage --- samples/move_workbook_sites.py | 2 +- tableauserverclient/models/tableau_auth.py | 18 ++++++++++++++++-- tableauserverclient/server/request_factory.py | 2 +- test/test_auth.py | 2 +- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index b609ecffb..d81c96767 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -65,7 +65,7 @@ def main(): error = "No site named {} found.".format(args.destination_site) raise LookupError(error) - tableau_auth.site = args.destination_site + tableau_auth.site_id = args.destination_site # Signing into another site requires another server object # because of the different auth token and site ID. diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index cdf0fb410..7670e2812 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -1,6 +1,20 @@ class TableauAuth(object): - def __init__(self, username, password, site='', user_id_to_impersonate=None): + def __init__(self, username, password, site=None, site_id='', 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) + site_id = site + self.user_id_to_impersonate = user_id_to_impersonate self.password = password - self.site = site + self.site_id = site_id self.username = username + + @property + def site(self): + import warnings + warnings.warn('TableauAuth.site is deprecated, use TableauAuth.site_id instead.', + DeprecationWarning) + return self.site_id diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 8f20cfa36..7d073108c 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -22,7 +22,7 @@ def signin_req(self, auth_item): credentials_element.attrib['name'] = auth_item.username credentials_element.attrib['password'] = auth_item.password site_element = ET.SubElement(credentials_element, 'site') - site_element.attrib['contentUrl'] = auth_item.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 a833cead8..870064db0 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -20,7 +20,7 @@ def test_sign_in(self): 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='Samples') + tableau_auth = TSC.TableauAuth('testuser', 'password', site_id='Samples') self.server.auth.sign_in(tableau_auth) self.assertEqual('eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l', self.server.auth_token) From 2f041d1a5c12bbbed2a0852ef16cd45fd0a83400 Mon Sep 17 00:00:00 2001 From: Kovner Date: Thu, 27 Oct 2016 14:54:10 -0700 Subject: [PATCH 22/32] Add Guest to site role enum (#83) Fixes #79 by adding 'Guest' to the site role enum --- 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 80cac09ce..49a048f69 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -14,6 +14,7 @@ class Roles: UnlicensedWithPublish = 'UnlicensedWithPublish' Viewer = 'Viewer' ViewerWithPublish = 'ViewerWithPublish' + Guest = 'Guest' class Auth: SAML = 'SAML' From 7c41a0a47233fe9fcd7fcb308d84955b939475d6 Mon Sep 17 00:00:00 2001 From: geordielad Date: Thu, 27 Oct 2016 19:50:05 -0400 Subject: [PATCH 23/32] Add connection credentials (#80) --- tableauserverclient/__init__.py | 2 +- tableauserverclient/models/__init__.py | 1 + .../models/connection_credentials.py | 24 +++++++++++++++ .../server/endpoint/datasources_endpoint.py | 8 +++-- .../server/endpoint/workbooks_endpoint.py | 8 +++-- tableauserverclient/server/request_factory.py | 30 ++++++++++++------- 6 files changed, 56 insertions(+), 17 deletions(-) create mode 100644 tableauserverclient/models/connection_credentials.py diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 9e56919c6..107b6b2d9 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,5 +1,5 @@ from .namespace import NAMESPACE -from .models import ConnectionItem, DatasourceItem,\ +from .models import ConnectionCredentials, ConnectionItem, DatasourceItem,\ GroupItem, PaginationItem, ProjectItem, ScheduleItem, \ SiteItem, TableauAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError, \ HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 276684d66..415c84147 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -1,3 +1,4 @@ +from .connection_credentials import ConnectionCredentials from .connection_item import ConnectionItem from .datasource_item import DatasourceItem from .exceptions import UnpopulatedPropertyError diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py new file mode 100644 index 000000000..d823b0b7f --- /dev/null +++ b/tableauserverclient/models/connection_credentials.py @@ -0,0 +1,24 @@ +from .property_decorators import property_is_boolean + + +class ConnectionCredentials(object): + """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. + + """ + + def __init__(self, name, password, embed=True): + self.name = name + self.password = password + self.embed = embed + + @property + def embed(self): + return self._embed + + @embed.setter + @property_is_boolean + def embed(self, value): + self._embed = value diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index ba49c768f..ecb66724c 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -94,7 +94,7 @@ def update(self, datasource_item): return updated_datasource._parse_common_tags(server_response.content) # Publish datasource - def publish(self, datasource_item, file_path, mode): + def publish(self, datasource_item, file_path, mode, connection_credentials=None): if not os.path.isfile(file_path): error = "File path does not lead to an existing file." raise IOError(error) @@ -122,14 +122,16 @@ def publish(self, datasource_item, file_path, mode): logger.info('Publishing {0} to server with chunking method (datasource 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.Datasource.publish_req_chunked(datasource_item) + xml_request, content_type = RequestFactory.Datasource.publish_req_chunked(datasource_item, + connection_credentials) 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.Datasource.publish_req(datasource_item, filename, - file_contents) + file_contents, + connection_credentials) server_response = self.post_request(url, xml_request, content_type) new_datasource = DatasourceItem.from_response(server_response.content)[0] logger.info('Published {0} (ID: {1})'.format(filename, new_datasource.id)) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index e60789f06..46ec3c5ee 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -140,7 +140,7 @@ def populate_preview_image(self, workbook_item): logger.info('Populated preview image for workbook (ID: {0})'.format(workbook_item.id)) # Publishes workbook. Chunking method if file over 64MB - def publish(self, workbook_item, file_path, mode): + def publish(self, workbook_item, file_path, mode, connection_credentials=None): if not os.path.isfile(file_path): error = "File path does not lead to an existing file." raise IOError(error) @@ -171,14 +171,16 @@ def publish(self, workbook_item, file_path, mode): 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) - xml_request, content_type = RequestFactory.Workbook.publish_req_chunked(workbook_item) + xml_request, content_type = RequestFactory.Workbook.publish_req_chunked(workbook_item, + connection_credentials) 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.Workbook.publish_req(workbook_item, filename, - file_contents) + file_contents, + connection_credentials) server_response = self.post_request(url, xml_request, content_type) new_workbook = WorkbookItem.from_response(server_response.content)[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 7d073108c..9a9bf53e1 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -30,12 +30,17 @@ def signin_req(self, auth_item): class DatasourceRequest(object): - def _generate_xml(self, datasource_item): + def _generate_xml(self, datasource_item, connection_credentials=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' return ET.tostring(xml_request) def update_req(self, datasource_item): @@ -49,15 +54,15 @@ def update_req(self, datasource_item): owner_element.attrib['id'] = datasource_item.owner_id return ET.tostring(xml_request) - def publish_req(self, datasource_item, filename, file_contents): - xml_request = self._generate_xml(datasource_item) + def publish_req(self, datasource_item, filename, file_contents, connection_credentials=None): + xml_request = self._generate_xml(datasource_item, connection_credentials) 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): - xml_request = self._generate_xml(datasource_item) + def publish_req_chunked(self, datasource_item, connection_credentials=None): + xml_request = self._generate_xml(datasource_item, connection_credentials) parts = {'request_payload': ('', xml_request, 'text/xml')} return _add_multipart(parts) @@ -260,7 +265,7 @@ def add_req(self, user_item): class WorkbookRequest(object): - def _generate_xml(self, workbook_item): + def _generate_xml(self, workbook_item, connection_credentials=None): xml_request = ET.Element('tsRequest') workbook_element = ET.SubElement(xml_request, 'workbook') workbook_element.attrib['name'] = workbook_item.name @@ -268,6 +273,11 @@ def _generate_xml(self, workbook_item): 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' return ET.tostring(xml_request) def update_req(self, workbook_item): @@ -283,15 +293,15 @@ 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): - xml_request = self._generate_xml(workbook_item) + def publish_req(self, workbook_item, filename, file_contents, connection_credentials=None): + xml_request = self._generate_xml(workbook_item, connection_credentials) 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): - xml_request = self._generate_xml(workbook_item) + def publish_req_chunked(self, workbook_item, connection_credentials=None): + xml_request = self._generate_xml(workbook_item, connection_credentials) parts = {'request_payload': ('', xml_request, 'text/xml')} return _add_multipart(parts) From 1cbc230e20075f21e4de50c0ac216cdbcd38ebe7 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Fri, 28 Oct 2016 11:08:24 -0700 Subject: [PATCH 24/32] Adding pagination sample (#72) * Adding pagination sample * pep8 fixes * Ben's feedback incorporated * Simplifying usage by eliminating the lambda --- samples/pagination_sample.py | 79 ++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 samples/pagination_sample.py diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py new file mode 100644 index 000000000..7bc5b8dcc --- /dev/null +++ b/samples/pagination_sample.py @@ -0,0 +1,79 @@ +#### +# This script demonstrates how to use pagination item that is returned as part +# of many of the .get() method calls. +# +# This script will iterate over every workbook that exists on the server using the +# pagination item to fetch additional pages as needed. +# +# While this sample uses workbook, this same technique will work with any of the .get() methods that return +# a pagination item +#### + +import argparse +import getpass +import logging +import os.path + +import tableauserverclient as TSC + + +class pagination_generator(object): + """ This class returns a generator that will iterate over all of the results. + + server is the server object that will be used when calling the callback. It will be passed + to the callback on each iteration + + Callback is expected to take a server object and a request options and return two values, an array of results, + and the pagination item from the current call. This will be used to build subsequent requests. + """ + + def __init__(self, fetch_more): + self._fetch_more = fetch_more + + def __call__(self): + current_item_list, last_pagination_item = self._fetch_more(None) # Prime the generator + count = 0 + + while count < last_pagination_item.total_available: + if len(current_item_list) == 0: + current_item_list, last_pagination_item = self._load_next_page(current_item_list, last_pagination_item) + + yield current_item_list.pop(0) + count += 1 + + def _load_next_page(self, current_item_list, last_pagination_item): + next_page = last_pagination_item.page_number + 1 + opts = TSC.RequestOptions(pagenumber=next_page, pagesize=last_pagination_item.page_size) + current_item_list, last_pagination_item = self._fetch_more(opts) + return current_item_list, last_pagination_item + + +def main(): + + parser = argparse.ArgumentParser(description='Return a list of all of the workbooks on your 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', + 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) + + # SIGN IN + + tableau_auth = TSC.TableauAuth(args.username, password) + server = TSC.Server(args.server) + + with server.auth.sign_in(tableau_auth): + generator = pagination_generator(server.workbooks.get) + print("Your server contains the following workbooks:\n") + for wb in generator(): + print(wb.name) + +if __name__ == '__main__': + main() From a6975db7071d24eafaf3d0efae65fec41396570e Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Fri, 28 Oct 2016 12:15:45 -0700 Subject: [PATCH 25/32] Implement get server info (#84) * Implement get server info * reverting a change that should not have made it into the previous commit --- tableauserverclient/models/__init__.py | 1 + .../models/server_info_item.py | 33 +++++++++++++++++++ .../server/endpoint/__init__.py | 1 + .../server/endpoint/endpoint.py | 11 +++++++ .../server/endpoint/server_info_endpoint.py | 21 ++++++++++++ tableauserverclient/server/server.py | 3 +- test/assets/server_info_get.xml | 6 ++++ test/test_server_info.py | 26 +++++++++++++++ 8 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 tableauserverclient/models/server_info_item.py create mode 100644 tableauserverclient/server/endpoint/server_info_endpoint.py create mode 100644 test/assets/server_info_get.xml create mode 100644 test/test_server_info.py diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 415c84147..b248ea399 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -7,6 +7,7 @@ from .pagination_item import PaginationItem from .project_item import ProjectItem from .schedule_item import ScheduleItem +from .server_info_item import ServerInfoItem from .site_item import SiteItem from .tableau_auth import TableauAuth from .user_item import UserItem diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py new file mode 100644 index 000000000..91900f850 --- /dev/null +++ b/tableauserverclient/models/server_info_item.py @@ -0,0 +1,33 @@ +import xml.etree.ElementTree as ET +from .. import NAMESPACE + + +class ServerInfoItem(object): + def __init__(self, product_version, build_number, rest_api_version): + self._product_version = product_version + self._build_number = build_number + self._rest_api_version = rest_api_version + + @property + def product_version(self): + return self._product_version + + @property + def build_number(self): + return self._build_number + + @property + def rest_api_version(self): + return self._rest_api_version + + @classmethod + def from_response(cls, resp): + parsed_response = ET.fromstring(resp) + product_version_tag = parsed_response.find('.//t:productVersion', namespaces=NAMESPACE) + rest_api_version_tag = parsed_response.find('.//t:restApiVersion', namespaces=NAMESPACE) + + build_number = product_version_tag.get('build', None) + product_version = product_version_tag.text + rest_api_version = rest_api_version_tag.text + + return cls(product_version, build_number, rest_api_version) diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 65e15c683..63d69510c 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -5,6 +5,7 @@ from .groups_endpoint import Groups from .projects_endpoint import Projects from .schedules_endpoint import Schedules +from .server_info_endpoint import ServerInfo from .sites_endpoint import Sites from .users_endpoint import Users from .views_endpoint import Views diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 98f451211..fd8cb3ac7 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,6 +1,7 @@ from .exceptions import ServerResponseError import logging + logger = logging.getLogger('tableau.endpoint') Success_codes = [200, 201, 204] @@ -15,6 +16,16 @@ def _check_status(server_response): if server_response.status_code not in Success_codes: raise ServerResponseError.from_response(server_response.content) + def get_unauthenticated_request(self, url, request_object=None): + if request_object is not None: + url = request_object.apply_query_params(url) + server_response = self.parent_srv.session.get(url, **self.parent_srv.http_options) + self._check_status(server_response) + if 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 get_request(self, url, request_object=None): if request_object is not None: url = request_object.apply_query_params(url) diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py new file mode 100644 index 000000000..901778c58 --- /dev/null +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -0,0 +1,21 @@ +from .endpoint import Endpoint +from ...models import ServerInfoItem +import logging + +logger = logging.getLogger('tableau.endpoint.server_info') + + +class ServerInfo(Endpoint): + def __init__(self, parent_srv): + super(Endpoint, self).__init__() + self.parent_srv = parent_srv + + @property + def baseurl(self): + return "{0}/serverInfo".format(self.parent_srv.baseurl) + + def get(self): + """ Retrieve the server info for the server. This is an unauthenticated call """ + server_response = self.get_unauthenticated_request(self.baseurl) + server_info = ServerInfoItem.from_response(server_response.content) + return server_info diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 8c28c1825..5e85063d2 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,5 +1,5 @@ from .exceptions import NotSignedInError -from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, Schedules +from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, Schedules, ServerInfo import requests @@ -27,6 +27,7 @@ def __init__(self, server_address): self.datasources = Datasources(self) self.projects = Projects(self) self.schedules = Schedules(self) + self.server_info = ServerInfo(self) def add_http_options(self, options_dict): self._http_options.update(options_dict) diff --git a/test/assets/server_info_get.xml b/test/assets/server_info_get.xml new file mode 100644 index 000000000..ce4e0b322 --- /dev/null +++ b/test/assets/server_info_get.xml @@ -0,0 +1,6 @@ + + +10.1.0 +2.4 + + \ No newline at end of file diff --git a/test/test_server_info.py b/test/test_server_info.py new file mode 100644 index 000000000..03e39210f --- /dev/null +++ b/test/test_server_info.py @@ -0,0 +1,26 @@ +import unittest +import os.path +import requests_mock +import tableauserverclient as TSC + +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') + + +class ServerInfoTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + self.server.version = '2.4' + self.baseurl = self.server.server_info.baseurl + + def test_server_info_get(self): + 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.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) From a4b90cadd3824ade7ef74b3dacbb768702f08587 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Fri, 28 Oct 2016 12:16:09 -0700 Subject: [PATCH 26/32] Prep version 0.2 (#86) * Adding CONTRIBUTORS file * revving version number --- CONTRIBUTORS.md | 16 ++++++++++++++++ setup.py | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 CONTRIBUTORS.md diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 000000000..c97e9301d --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,16 @@ +This project wouldn't be possible without our amazing contributors. + +The following people have contributed to this project to make it possible, and we thank them for their contributions! + +## Contributors + +* [geordielad](https://github.com/geordielad) +* [kovner](https://github.com/kovner) + + +## Core Team + +* [shinchris](https://github.com/shinchris) +* [lgraber](https://github.com/lgraber) +* [t8y8](https://github.com/t8y8) +* [RussTheAerialist](https://github.com/RussTheAerialist) diff --git a/setup.py b/setup.py index 7487acdf3..e4214aa70 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name='tableauserverclient', - version='0.2.dev0', + version='0.2', author='Tableau', author_email='github@tableau.com', url='https://github.com/tableau/server-client-python', From 6a48ddcf703bff8aa63e7348e41aa72965f111be Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Fri, 28 Oct 2016 14:28:17 -0700 Subject: [PATCH 27/32] Cleanup duplications (#87) * Cleaning up the base endpoint object to reduce duplication * Deduping the ctors for Endpoints * Adding comment to clarify the condition on server_response.encoding * fixing pep8 problems * Auth Endpoint had two __init__, getting rid of both of them because they aren't needed * make_headers -> make_common_headers * token -> auth_token --- .../server/endpoint/auth_endpoint.py | 4 - .../server/endpoint/datasources_endpoint.py | 4 - .../server/endpoint/endpoint.py | 83 +++++++++---------- .../server/endpoint/fileuploads_endpoint.py | 3 +- .../server/endpoint/groups_endpoint.py | 4 - .../server/endpoint/projects_endpoint.py | 4 - .../server/endpoint/schedules_endpoint.py | 4 - .../server/endpoint/server_info_endpoint.py | 4 - .../server/endpoint/sites_endpoint.py | 4 - .../server/endpoint/users_endpoint.py | 4 - .../server/endpoint/views_endpoint.py | 4 - .../server/endpoint/workbooks_endpoint.py | 4 - 12 files changed, 41 insertions(+), 85 deletions(-) diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 9e316d042..ed42d32e6 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -17,10 +17,6 @@ def __enter__(self): def __exit__(self, exc_type, exc_val, exc_tb): self._callback() - def __init__(self, parent_srv): - super(Endpoint, self).__init__() - self.parent_srv = parent_srv - @property def baseurl(self): return "{0}/auth".format(self.parent_srv.baseurl) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index ecb66724c..e8e4e4bf6 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -16,10 +16,6 @@ class Datasources(Endpoint): - def __init__(self, parent_srv): - super(Endpoint, self).__init__() - self.parent_srv = parent_srv - @property def baseurl(self): return "{0}/sites/{1}/datasources".format(self.parent_srv.baseurl, self.parent_srv.site_id) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index fd8cb3ac7..b532abfcb 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -8,64 +8,61 @@ class Endpoint(object): - def __init__(self): - self.parent_srv = None + def __init__(self, parent_srv): + self.parent_srv = parent_srv @staticmethod - def _check_status(server_response): - if server_response.status_code not in Success_codes: - raise ServerResponseError.from_response(server_response.content) + def _make_common_headers(auth_token, content_type): + retval = {} + if auth_token is not None: + retval['x-tableau-auth'] = auth_token + if content_type is not None: + retval['content-type'] = content_type - def get_unauthenticated_request(self, url, request_object=None): + def _make_request(self, method, url, content=None, request_object=None, auth_token=None, content_type=None): if request_object is not None: url = request_object.apply_query_params(url) - server_response = self.parent_srv.session.get(url, **self.parent_srv.http_options) + parameters = {} + parameters.update(self.parent_srv.http_options) + parameters['headers'] = Endpoint._make_common_headers(auth_token, content_type) + + if content is not None: + parameters['data'] = content + + server_response = method(url, **parameters) 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 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 + @staticmethod + def _check_status(server_response): + if server_response.status_code not in Success_codes: + raise ServerResponseError.from_response(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) + def get_request(self, url, request_object=None): - if request_object is not None: - url = request_object.apply_query_params(url) - auth_token = self.parent_srv.auth_token - server_response = self.parent_srv.session.get(url, - headers={'x-tableau-auth': auth_token}, - **self.parent_srv.http_options) - self._check_status(server_response) - if 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 + return self._make_request(self.parent_srv.session.get, url, auth_token=self.parent_srv.auth_token, + request_object=request_object) def delete_request(self, url): - auth_token = self.parent_srv.auth_token - server_response = self.parent_srv.session.delete(url, - headers={'x-tableau-auth': auth_token}, - **self.parent_srv.http_options) - self._check_status(server_response) + # 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'): - auth_token = self.parent_srv.auth_token - server_response = self.parent_srv.session.put(url, data=xml_request, - headers={'x-tableau-auth': auth_token, - 'content-type': content_type}, - **self.parent_srv.http_options) - self._check_status(server_response) - if 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 + 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'): - auth_token = self.parent_srv.auth_token - server_response = self.parent_srv.session.post(url, data=xml_request, - headers={'x-tableau-auth': auth_token, - 'content-type': content_type}, - **self.parent_srv.http_options) - self._check_status(server_response) - if 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 + return self._make_request(self.parent_srv.session.post, url, + content=xml_request, + auth_token=self.parent_srv.auth_token, + content_type=content_type) diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 65a3b2526..df26d3db5 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -13,8 +13,7 @@ class Fileuploads(Endpoint): def __init__(self, parent_srv): - super(Endpoint, self).__init__() - self.parent_srv = parent_srv + super(Fileuploads, self).__init__(parent_srv) self.upload_id = '' @property diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index a21888695..e1eb2ecfc 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -8,10 +8,6 @@ class Groups(Endpoint): - def __init__(self, parent_srv): - super(Endpoint, self).__init__() - self.parent_srv = parent_srv - @property def baseurl(self): return "{0}/sites/{1}/groups".format(self.parent_srv.baseurl, self.parent_srv.site_id) diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 484ee2aff..b146d4418 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -8,10 +8,6 @@ class Projects(Endpoint): - def __init__(self, parent_srv): - super(Endpoint, self).__init__() - self.parent_srv = parent_srv - @property def baseurl(self): return "{0}/sites/{1}/projects".format(self.parent_srv.baseurl, self.parent_srv.site_id) diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 9b4721941..705f9577b 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -8,10 +8,6 @@ class Schedules(Endpoint): - def __init__(self, parent_srv): - super(Endpoint, self).__init__() - self.parent_srv = parent_srv - @property def baseurl(self): return "{0}/schedules".format(self.parent_srv.baseurl) diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index 901778c58..1fb17f26f 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -6,10 +6,6 @@ class ServerInfo(Endpoint): - def __init__(self, parent_srv): - super(Endpoint, self).__init__() - self.parent_srv = parent_srv - @property def baseurl(self): return "{0}/serverInfo".format(self.parent_srv.baseurl) diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 51736ffc7..3977ad0f2 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -8,10 +8,6 @@ class Sites(Endpoint): - def __init__(self, parent_srv): - super(Endpoint, self).__init__() - self.parent_srv = parent_srv - @property def baseurl(self): return "{0}/sites".format(self.parent_srv.baseurl) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 1bc32d50e..d5b5155fa 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -8,10 +8,6 @@ class Users(Endpoint): - def __init__(self, parent_srv): - super(Endpoint, self).__init__() - self.parent_srv = parent_srv - @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 1ccc418f4..2b5f0e5dd 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -7,10 +7,6 @@ class Views(Endpoint): - def __init__(self, parent_srv): - super(Endpoint, self).__init__() - self.parent_srv = parent_srv - @property def baseurl(self): return "{0}/sites/{1}".format(self.parent_srv.baseurl, self.parent_srv.site_id) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 46ec3c5ee..6aabc6029 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -17,10 +17,6 @@ class Workbooks(Endpoint): - def __init__(self, parent_srv): - super(Endpoint, self).__init__() - self.parent_srv = parent_srv - @property def baseurl(self): return "{0}/sites/{1}/workbooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) From 101eedba15b45bd487204663f92424132920a706 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Sun, 30 Oct 2016 22:36:27 -0500 Subject: [PATCH 28/32] Fix missing token on requests (#89) --- tableauserverclient/server/endpoint/endpoint.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index b532abfcb..c90b91004 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -13,11 +13,13 @@ def __init__(self, parent_srv): @staticmethod def _make_common_headers(auth_token, content_type): - retval = {} + headers = {} if auth_token is not None: - retval['x-tableau-auth'] = auth_token + headers['x-tableau-auth'] = auth_token if content_type is not None: - retval['content-type'] = content_type + headers['content-type'] = content_type + + return headers def _make_request(self, method, url, content=None, request_object=None, auth_token=None, content_type=None): if request_object is not None: From a6d0ede196b9e62ce14782870888b8fdf3db28e2 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Mon, 31 Oct 2016 16:27:06 -0500 Subject: [PATCH 29/32] Implement Pager for auto-paging requests (#90) Add a `Pager` object that can wrap any `Endpoint` with a `.get` method. This returns a generator that can be used anywhere a standard iterator can. It also takes request_options and can start from any page, and use any page size. It will make a single call, and yield items (in proper order) until you reach the end of that page, it will then call `Endpoint.get` again and fetch the next page. * If you start midway (page 5 of 10) the iterator will only work from page 5 forward. * Sort and Filter are supported * If the count changes on the Server-side (eg someone deleted an item while you were iterating) it will raise `StopIteration` and exit gracefully * Tested with unittests and against a live server, ran sample too Initial Implementation based on sample by @RussTheAerialist --- samples/pagination_sample.py | 57 +++++++---------- tableauserverclient/__init__.py | 2 +- tableauserverclient/server/__init__.py | 1 + tableauserverclient/server/pager.py | 43 +++++++++++++ tableauserverclient/server/server.py | 1 + test/assets/workbook_get_page_1.xml | 11 ++++ test/assets/workbook_get_page_2.xml | 14 ++++ test/assets/workbook_get_page_3.xml | 10 +++ test/test_pager.py | 88 ++++++++++++++++++++++++++ 9 files changed, 193 insertions(+), 34 deletions(-) create mode 100644 tableauserverclient/server/pager.py create mode 100644 test/assets/workbook_get_page_1.xml create mode 100644 test/assets/workbook_get_page_2.xml create mode 100644 test/assets/workbook_get_page_3.xml create mode 100644 test/test_pager.py diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index 7bc5b8dcc..882fc85ad 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -17,37 +17,6 @@ import tableauserverclient as TSC -class pagination_generator(object): - """ This class returns a generator that will iterate over all of the results. - - server is the server object that will be used when calling the callback. It will be passed - to the callback on each iteration - - Callback is expected to take a server object and a request options and return two values, an array of results, - and the pagination item from the current call. This will be used to build subsequent requests. - """ - - def __init__(self, fetch_more): - self._fetch_more = fetch_more - - def __call__(self): - current_item_list, last_pagination_item = self._fetch_more(None) # Prime the generator - count = 0 - - while count < last_pagination_item.total_available: - if len(current_item_list) == 0: - current_item_list, last_pagination_item = self._load_next_page(current_item_list, last_pagination_item) - - yield current_item_list.pop(0) - count += 1 - - def _load_next_page(self, current_item_list, last_pagination_item): - next_page = last_pagination_item.page_number + 1 - opts = TSC.RequestOptions(pagenumber=next_page, pagesize=last_pagination_item.page_size) - current_item_list, last_pagination_item = self._fetch_more(opts) - return current_item_list, last_pagination_item - - def main(): parser = argparse.ArgumentParser(description='Return a list of all of the workbooks on your server') @@ -70,10 +39,32 @@ def main(): server = TSC.Server(args.server) with server.auth.sign_in(tableau_auth): - generator = pagination_generator(server.workbooks.get) + + # 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 + print("Your server contains the following workbooks:\n") - for wb in generator(): + for wb in TSC.Pager(server.workbooks): print(wb.name) + # 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 + + # 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 + + # >>> request_options = TSC.RequestOptions(pagesize=1000) + # >>> all_workbooks = list(TSC.Pager(server.workbooks, request_options)) + if __name__ == '__main__': main() diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 107b6b2d9..c7a628d83 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -4,7 +4,7 @@ SiteItem, TableauAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError, \ HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem from .server import RequestOptions, Filter, Sort, Server, ServerResponseError,\ - MissingRequiredFieldError, NotSignedInError + MissingRequiredFieldError, NotSignedInError, Pager __version__ = '0.0.1' __VERSION__ = __version__ diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index a8b78b7fc..e74e3cea6 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -8,4 +8,5 @@ from .endpoint import Auth, Datasources, Endpoint, Groups, Projects, Schedules, \ Sites, Users, Views, Workbooks, ServerResponseError, MissingRequiredFieldError from .server import Server +from .pager import Pager from .exceptions import NotSignedInError diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py new file mode 100644 index 000000000..eaad398af --- /dev/null +++ b/tableauserverclient/server/pager.py @@ -0,0 +1,43 @@ +from . import RequestOptions + + +class Pager(object): + """ + Generator that takes an endpoint with `.get` and lazily loads items from Server. + Supports all `RequestOptions` including starting on any page. + """ + + def __init__(self, endpoint, request_opts=None): + self._endpoint = endpoint.get + self._options = request_opts + + # 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 + + def __iter__(self): + # Fetch the first page + current_item_list, last_pagination_item = self._endpoint(self._options) + + # Get the rest on demand as a generator + while self._count < last_pagination_item.total_available: + if len(current_item_list) == 0: + 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 + raise StopIteration + + 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 diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 5e85063d2..2cb08a892 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,5 +1,6 @@ from .exceptions import NotSignedInError from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, Schedules, ServerInfo + import requests diff --git a/test/assets/workbook_get_page_1.xml b/test/assets/workbook_get_page_1.xml new file mode 100644 index 000000000..a5dfdcf89 --- /dev/null +++ b/test/assets/workbook_get_page_1.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/test/assets/workbook_get_page_2.xml b/test/assets/workbook_get_page_2.xml new file mode 100644 index 000000000..456cc1bcf --- /dev/null +++ b/test/assets/workbook_get_page_2.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/test/assets/workbook_get_page_3.xml b/test/assets/workbook_get_page_3.xml new file mode 100644 index 000000000..e2fad1f2b --- /dev/null +++ b/test/assets/workbook_get_page_3.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/test/test_pager.py b/test/test_pager.py new file mode 100644 index 000000000..e3cec1ce8 --- /dev/null +++ b/test/test_pager.py @@ -0,0 +1,88 @@ +import unittest +import os +import requests_mock +import tableauserverclient as TSC + +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') + + +class PagerTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + + # 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_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 requests_mock.mock() as m: + # Register Pager with default request options + m.get(self.baseurl, text=page_1) + + # Register Pager with some pages + m.get(self.baseurl + "?pageNumber=1&pageSize=1", text=page_1) + m.get(self.baseurl + "?pageNumber=2&pageSize=1", text=page_2) + m.get(self.baseurl + "?pageNumber=3&pageSize=1", text=page_3) + + # No options should get all 3 + workbooks = list(TSC.Pager(self.server.workbooks)) + self.assertTrue(len(workbooks) == 3) + + # 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') + + 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 requests_mock.mock() as m: + # Register Pager with some pages + m.get(self.baseurl + "?pageNumber=1&pageSize=1", text=page_1) + m.get(self.baseurl + "?pageNumber=2&pageSize=1", text=page_2) + m.get(self.baseurl + "?pageNumber=3&pageSize=1", text=page_3) + m.get(self.baseurl + "?pageNumber=1&pageSize=3", text=page_1) + + # Starting on page 2 should get 2 out of 3 + opts = TSC.RequestOptions(2, 1) + workbooks = list(TSC.Pager(self.server.workbooks, opts)) + self.assertTrue(len(workbooks) == 2) + + # 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') + + # 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') + + # Starting on 3 with pagesize of 1 should get the last item + opts = TSC.RequestOptions(3, 1) + workbooks = list(TSC.Pager(self.server.workbooks, opts)) + self.assertTrue(len(workbooks) == 1) + # Should have the last workbook + wb3 = workbooks.pop() + self.assertEqual(wb3.name, 'Page3Workbook') From b6b0834fdc39b7a317758ef7e7bed24821b4af8d Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Tue, 1 Nov 2016 11:44:31 -0700 Subject: [PATCH 30/32] Adding changelog for release --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..a58e98bb1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +## 0.2 (02 November 2016) + +* Added Initial Schedules Support (#48) +* Added Initial Create Group endpoint (#69) +* Added Connection Credentials for publishing datasources (#80) +* Added Pager object for handling pagination results and sample (#72, #90) +* Added ServerInfo endpoint (#84) +* Code Cleanup +* Bugfixes + +## 0.1 (12 September 2016) + +* Initial Release to the world From 1417564dda5928a8349fd24bd8a5155be3d34d82 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Tue, 1 Nov 2016 11:55:48 -0700 Subject: [PATCH 31/32] Incorporating Tyler's feedback --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a58e98bb1..9cbba0ac1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,10 @@ * Added Initial Schedules Support (#48) * Added Initial Create Group endpoint (#69) -* Added Connection Credentials for publishing datasources (#80) +* Added Connection Credentials for publishing datasources/workbooks (#80) * Added Pager object for handling pagination results and sample (#72, #90) * Added ServerInfo endpoint (#84) +* Deprecated `site_id` as a parameter to `TableauAuth` in favor of `site` * Code Cleanup * Bugfixes From ee4b36ab2de2865d828dc01d2d45cd74cefed6b7 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Tue, 1 Nov 2016 13:47:58 -0700 Subject: [PATCH 32/32] oops, scratch that, reverse it: site_id is the right one --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cbba0ac1..d9aa404ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ * Added Connection Credentials for publishing datasources/workbooks (#80) * Added Pager object for handling pagination results and sample (#72, #90) * Added ServerInfo endpoint (#84) -* Deprecated `site_id` as a parameter to `TableauAuth` in favor of `site` +* Deprecated `site` as a parameter to `TableauAuth` in favor of `site_id` * Code Cleanup * Bugfixes