From 40cf42a5554c8af683d9dcec01163236307a6f9e Mon Sep 17 00:00:00 2001 From: Bryant Howell Date: Tue, 10 Dec 2019 15:36:05 -0600 Subject: [PATCH 01/14] Added a new exception and handling around publish a .hyper file directly via publish_datasource. --- tableau_exceptions.py | 22 +- tableau_rest_api/methods/rest_api_base.py | 266 +++++++++++----------- tableau_rest_api/rest_xml_request.py | 4 + 3 files changed, 153 insertions(+), 139 deletions(-) diff --git a/tableau_exceptions.py b/tableau_exceptions.py index 0c14459..3f5bece 100644 --- a/tableau_exceptions.py +++ b/tableau_exceptions.py @@ -1,41 +1,49 @@ # -*- coding: utf-8 -*- +class TableauException(Exception): + def __str__(self): + return "{} Exception: {}".format(self.__class__.__name__,self.msg) -class NoMatchFoundException(Exception): +class NoMatchFoundException(TableauException): def __init__(self, msg): self.msg = msg -class AlreadyExistsException(Exception): +class AlreadyExistsException(TableauException): def __init__(self, msg, existing_luid): self.msg = msg self.existing_luid = existing_luid # Raised when an action is attempted that requires being signed into that site -class NotSignedInException(Exception): +class NotSignedInException(TableauException): def __init__(self, msg): self.msg = msg # Raise when something an option is passed that is not valid in the REST API (site_role, permissions name, etc) -class InvalidOptionException(Exception): +class InvalidOptionException(TableauException): def __init__(self, msg): self.msg = msg -class RecoverableHTTPException(Exception): +class RecoverableHTTPException(TableauException): def __init__(self, http_code, tableau_error_code, luid): self.http_code = http_code self.tableau_error_code = tableau_error_code self.luid = luid +class PossibleInvalidPublishException(TableauException): + def __init__(self, http_code, tableau_error_code, msg): + self.http_code = http_code + self.tableau_error_code = tableau_error_code + self.msg = msg -class MultipleMatchesFoundException(Exception): +class MultipleMatchesFoundException(TableauException): def __init__(self, count): self.msg = 'Found {} matches for the request, something has the same name'.format(str(count)) -class NoResultsException(Exception): +class NoResultsException(TableauException): def __init__(self, msg): self.msg = msg diff --git a/tableau_rest_api/methods/rest_api_base.py b/tableau_rest_api/methods/rest_api_base.py index 1be9a22..d03424b 100644 --- a/tableau_rest_api/methods/rest_api_base.py +++ b/tableau_rest_api/methods/rest_api_base.py @@ -877,144 +877,146 @@ def _publish_content(self, content_type: str, content_filename: str, content_nam # Open the file to be uploaded try: content_file = open(content_filename, 'rb') - file_size = os.path.getsize(content_filename) - file_size_mb = float(file_size) / float(1000000) - self.log("File {} is size {} MBs".format(content_filename, file_size_mb)) - final_filename = content_filename - - # Request type is mixed and require a boundary - boundary_string = self.generate_boundary_string() - - # Create the initial XML portion of the request - publish_request = bytes("--{}\r\n".format(boundary_string).encode('utf-8')) - publish_request += bytes('Content-Disposition: name="request_payload"\r\n'.encode('utf-8')) - publish_request += bytes('Content-Type: text/xml\r\n\r\n'.encode('utf-8')) - - # Build publish request in ElementTree then convert at publish - publish_request_xml = ET.Element('tsRequest') - # could be either workbook, datasource, or flow - t1 = ET.Element(content_type) - t1.set('name', content_name) - if show_tabs is not False: - t1.set('showTabs', str(show_tabs).lower()) - if generate_thumbnails_as_username_or_luid is not None: - if self.is_luid(generate_thumbnails_as_username_or_luid): - thumbnail_user_luid = generate_thumbnails_as_username_or_luid - else: - thumbnail_user_luid = self.query_user_luid(generate_thumbnails_as_username_or_luid) - t1.set('generateThumbnailsAsUser', thumbnail_user_luid) - - if connection_username is not None: - cc = ET.Element('connectionCredentials') - cc.set('name', connection_username) - if oauth_flag is True: - cc.set('oAuth', "True") - if connection_password is not None: - cc.set('password', connection_password) - cc.set('embed', str(save_credentials).lower()) - t1.append(cc) - - # Views to Hide in Workbooks from 3.2 - if views_to_hide_list is not None: - if len(views_to_hide_list) > 0: - vs = ET.Element('views') - for view_name in views_to_hide_list: - v = ET.Element('view') - v.set('name', view_name) - v.set('hidden', 'true') - t1.append(vs) - - # Description only allowed for Flows as of 3.3 - if description is not None: - t1.set('description', description) - p = ET.Element('project') - p.set('id', project_luid) - t1.append(p) - publish_request_xml.append(t1) - - encoded_request = ET.tostring(publish_request_xml, encoding='utf-8') - - publish_request += bytes(encoded_request) - publish_request += bytes("\r\n--{}".format(boundary_string).encode('utf-8')) - - # Upload as single if less than file_size_limit MB - if file_size_mb <= single_upload_limit: - # If part of a single upload, this if the next portion - self.log("Less than {} MB, uploading as a single call".format(str(single_upload_limit))) - publish_request += bytes('\r\n'.encode('utf-8')) - publish_request += bytes('Content-Disposition: name="tableau_{}"; filename="{}"\r\n'.format( - content_type, final_filename).encode('utf-8')) - publish_request += bytes('Content-Type: application/octet-stream\r\n\r\n'.encode('utf-8')) - - # Content needs to be read unencoded from the file - content = content_file.read() - - # Add to string as regular binary, no encoding - publish_request += content - - publish_request += bytes("\r\n--{}--".format(boundary_string).encode('utf-8')) - - url = self.build_api_url("{}s").format(content_type) - - # Allow additional parameters on the publish url - if len(url_params) > 0: - additional_params = '?' - i = 1 - for param in url_params: - if i > 1: - additional_params += "&" - additional_params += "{}={}".format(param, str(url_params[param]).lower()) - i += 1 - url += additional_params - - content_file.close() - if temp_wb_filename is not None: - os.remove(temp_wb_filename) - if cleanup_temp_file is True: - os.remove(final_filename) - return self.send_publish_request(url, None, publish_request, boundary_string) - # Break up into chunks for upload - else: - self.log("Greater than 10 MB, uploading in chunks") - upload_session_id = self.initiate_file_upload() - - # Upload each chunk - for piece in self.read_file_in_chunks(content_file): - self.log("Appending chunk to upload session {}".format(upload_session_id)) - self.append_to_file_upload(upload_session_id, piece, final_filename) - - # Finalize the publish - url = self.build_api_url("{}s").format(content_type) + "?uploadSessionId={}".format( - upload_session_id) + "&{}Type={}".format(content_type, file_extension) - - # Allow additional parameters on the publish url - if len(url_params) > 0: - additional_params = '&' - i = 1 - for param in url_params: - if i > 1: - additional_params += "&" - additional_params += "{}={}".format(param, str(url_params[param]).lower()) - i += 1 - url += additional_params - - publish_request += bytes("--".encode('utf-8')) # Need to finish off the last boundary - self.log("Finishing the upload with a publish request") - content_file.close() - if temp_wb_filename is not None: - os.remove(temp_wb_filename) - if cleanup_temp_file is True: - os.remove(final_filename) - return self.send_publish_request(url=url, xml_request=None, content=publish_request, - boundary_string=boundary_string) - except IOError: print("Error: File '{}' cannot be opened to upload".format(content_filename)) raise + file_size = os.path.getsize(content_filename) + file_size_mb = float(file_size) / float(1000000) + self.log("File {} is size {} MBs".format(content_filename, file_size_mb)) + final_filename = content_filename + + # Request type is mixed and require a boundary + boundary_string = self.generate_boundary_string() + + # Create the initial XML portion of the request + publish_request = bytes("--{}\r\n".format(boundary_string).encode('utf-8')) + publish_request += bytes('Content-Disposition: name="request_payload"\r\n'.encode('utf-8')) + publish_request += bytes('Content-Type: text/xml\r\n\r\n'.encode('utf-8')) + + # Build publish request in ElementTree then convert at publish + publish_request_xml = ET.Element('tsRequest') + # could be either workbook, datasource, or flow + t1 = ET.Element(content_type) + t1.set('name', content_name) + if show_tabs is not False: + t1.set('showTabs', str(show_tabs).lower()) + if generate_thumbnails_as_username_or_luid is not None: + if self.is_luid(generate_thumbnails_as_username_or_luid): + thumbnail_user_luid = generate_thumbnails_as_username_or_luid + else: + thumbnail_user_luid = self.query_user_luid(generate_thumbnails_as_username_or_luid) + t1.set('generateThumbnailsAsUser', thumbnail_user_luid) + + if connection_username is not None: + cc = ET.Element('connectionCredentials') + cc.set('name', connection_username) + if oauth_flag is True: + cc.set('oAuth', "True") + if connection_password is not None: + cc.set('password', connection_password) + cc.set('embed', str(save_credentials).lower()) + t1.append(cc) + + # Views to Hide in Workbooks from 3.2 + if views_to_hide_list is not None: + if len(views_to_hide_list) > 0: + vs = ET.Element('views') + for view_name in views_to_hide_list: + v = ET.Element('view') + v.set('name', view_name) + v.set('hidden', 'true') + t1.append(vs) + + # Description only allowed for Flows as of 3.3 + if description is not None: + t1.set('description', description) + p = ET.Element('project') + p.set('id', project_luid) + t1.append(p) + publish_request_xml.append(t1) + + encoded_request = ET.tostring(publish_request_xml, encoding='utf-8') + + publish_request += bytes(encoded_request) + publish_request += bytes("\r\n--{}".format(boundary_string).encode('utf-8')) + + # Upload as single if less than file_size_limit MB + if file_size_mb <= single_upload_limit: + # If part of a single upload, this if the next portion + self.log("Less than {} MB, uploading as a single call".format(str(single_upload_limit))) + publish_request += bytes('\r\n'.encode('utf-8')) + publish_request += bytes('Content-Disposition: name="tableau_{}"; filename="{}"\r\n'.format( + content_type, final_filename).encode('utf-8')) + publish_request += bytes('Content-Type: application/octet-stream\r\n\r\n'.encode('utf-8')) + + # Content needs to be read unencoded from the file + content = content_file.read() + + # Add to string as regular binary, no encoding + publish_request += content + + publish_request += bytes("\r\n--{}--".format(boundary_string).encode('utf-8')) + + url = self.build_api_url("{}s").format(content_type) + + # Allow additional parameters on the publish url + if len(url_params) > 0: + additional_params = '?' + i = 1 + for param in url_params: + if i > 1: + additional_params += "&" + additional_params += "{}={}".format(param, str(url_params[param]).lower()) + i += 1 + url += additional_params + + content_file.close() + if temp_wb_filename is not None: + os.remove(temp_wb_filename) + if cleanup_temp_file is True: + os.remove(final_filename) + + results = self.send_publish_request(url=url, xml_request=None, content=publish_request, + boundary_string=boundary_string) + return results + # Break up into chunks for upload + else: + self.log("Greater than 10 MB, uploading in chunks") + upload_session_id = self.initiate_file_upload() + + # Upload each chunk + for piece in self.read_file_in_chunks(content_file): + self.log("Appending chunk to upload session {}".format(upload_session_id)) + self.append_to_file_upload(upload_session_id, piece, final_filename) + + # Finalize the publish + url = self.build_api_url("{}s").format(content_type) + "?uploadSessionId={}".format( + upload_session_id) + "&{}Type={}".format(content_type, file_extension) + + # Allow additional parameters on the publish url + if len(url_params) > 0: + additional_params = '&' + i = 1 + for param in url_params: + if i > 1: + additional_params += "&" + additional_params += "{}={}".format(param, str(url_params[param]).lower()) + i += 1 + url += additional_params + + publish_request += bytes("--".encode('utf-8')) # Need to finish off the last boundary + self.log("Finishing the upload with a publish request") + content_file.close() + if temp_wb_filename is not None: + os.remove(temp_wb_filename) + if cleanup_temp_file is True: + os.remove(final_filename) + return self.send_publish_request(url=url, xml_request=None, content=publish_request, + boundary_string=boundary_string) if file_extension is None: raise InvalidOptionException( - "File {} does not have an acceptable extension. Should be .twb,.twbx,.tde,.tdsx,.tds,.tde".format( + "File {} does not have an acceptable extension. Should be .twb,.twbx,.tde,.tdsx,.tds,.tde, .tfl, .tlfx, .hyper".format( content_filename)) def initiate_file_upload(self) -> str: diff --git a/tableau_rest_api/rest_xml_request.py b/tableau_rest_api/rest_xml_request.py index c7a0aa0..47f649a 100644 --- a/tableau_rest_api/rest_xml_request.py +++ b/tableau_rest_api/rest_xml_request.py @@ -237,6 +237,10 @@ def _handle_http_error(self, response, e): if status_code == 409: self.log('HTTP 409 error, most likely an already exists') raise RecoverableHTTPException(status_code, error_code, detail_luid) + # Invalid Hyper Extract publish does this + elif status_code == 400 and self._http_verb == 'post': + if error_code == '400011': + raise PossibleInvalidPublishException(http_code=400, error_code='400011', msg="400011 on a Publish of a .hyper file could caused when the Hyper file either more than one table or the single table is not named 'Extract'.") else: raise e From 5d55b87b8744410d7e6df356f407af02a6b8003a Mon Sep 17 00:00:00 2001 From: Bryant Howell Date: Wed, 11 Dec 2019 06:59:59 -0600 Subject: [PATCH 02/14] Oddly enough 'initialize' was misspelled in one of the methods name, but consistently. Corrected now --- tableau_documents/tableau_file.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tableau_documents/tableau_file.py b/tableau_documents/tableau_file.py index 9f4b712..2bec8dc 100644 --- a/tableau_documents/tableau_file.py +++ b/tableau_documents/tableau_file.py @@ -322,10 +322,10 @@ def __init__(self, filename: str, logger_obj: Optional[Logger] = None): self.file_replacement_map: Dict = {} # Packaged up nicely but always run in constructor - self._open_file_and_intialize(filename=filename) + self._open_file_and_initialize(filename=filename) @abstractmethod - def _open_file_and_intialize(self, filename): + def _open_file_and_initialize(self, filename): pass @property @@ -353,7 +353,7 @@ def save_new_file(self, new_filename_no_extension: str) -> str: class TDSX(DatasourceFileInterface, TableauPackagedFile): - def _open_file_and_intialize(self, filename): + def _open_file_and_initialize(self, filename): try: file_obj = open(filename, 'rb') self.log('File type is {}'.format(self.file_type)) @@ -459,7 +459,7 @@ class TWBX(DatasourceFileInterface, TableauPackagedFile): #self._open_file_and_intialize(filename=filename) - def _open_file_and_intialize(self, filename): + def _open_file_and_initialize(self, filename): try: file_obj = open(filename, 'rb') self.log('File type is {}'.format(self.file_type)) From 3635a1c7c7c14f0a771445a5f85729492338f19a Mon Sep 17 00:00:00 2001 From: Bryant Howell Date: Wed, 11 Dec 2019 13:32:34 -0600 Subject: [PATCH 03/14] Updated the samples to show the new get_permissions_obj() method across the board. Also added a TableauRestXml class to hold static info and methods that various other objects might want to access. This is similar to the old TableauBase class but there is not not a need for everything to descend from it --- examples/create_site_sample.py | 24 +++---- examples/user_sync_sample.py | 4 +- tableau_documents/tableau_datasource.py | 2 +- tableau_documents/tableau_file.py | 2 + tableau_rest_api/methods/workbook.py | 2 +- tableau_rest_api/published_content.py | 15 ++-- tableau_rest_xml.py | 93 +++++++++++++++++++++++++ 7 files changed, 117 insertions(+), 25 deletions(-) create mode 100644 tableau_rest_xml.py diff --git a/examples/create_site_sample.py b/examples/create_site_sample.py index c42e521..570ab46 100644 --- a/examples/create_site_sample.py +++ b/examples/create_site_sample.py @@ -46,23 +46,23 @@ def tableau_rest_api_connection_version(): # Add in any default permissions you'd like at this point - admin_perms = default_proj.create_project_permissions_object_for_group(group_name_or_luid='Administrators', + admin_perms = default_proj.get_permissions_obj(group_name_or_luid='Administrators', role='Project Leader') - default_proj.set_permissions_by_permissions_obj_list([admin_perms, ]) + default_proj.set_permissions([admin_perms, ]) - admin_perms = default_proj.create_workbook_permissions_object_for_group(group_name_or_luid='Administrators', + admin_perms = default_proj.workbook_defaults.get_permissions_obj(group_name_or_luid='Administrators', role='Editor') admin_perms.set_capability(capability_name='Download Full Data', mode='Deny') - default_proj.workbook_defaults.set_permissions_by_permissions_obj_list([admin_perms, ]) + default_proj.workbook_defaults.set_permissions([admin_perms, ]) - admin_perms = default_proj.create_datasource_permissions_object_for_group(group_name_or_luid='Administrators', + admin_perms = default_proj.datasource_defaults.get_permissions_obj(group_name_or_luid='Administrators', role='Editor') - default_proj.datasource_defaults.set_permissions_by_permissions_obj_list([admin_perms, ]) + default_proj.datasource_defaults.set_permissions([admin_perms, ]) # Change one of these - new_perms = default_proj.create_project_permissions_object_for_group(group_name_or_luid='Administrators', + new_perms = default_proj.get_permissions_obj(group_name_or_luid='Administrators', role='Publisher') - default_proj.set_permissions_by_permissions_obj_list([new_perms, ]) + default_proj.set_permissions([new_perms, ]) # Create Additional Projects projects_to_create = ['Sandbox', 'Data Source Definitions', 'UAT', 'Finance', 'Real Financials'] @@ -117,21 +117,21 @@ def tableau_server_rest_version(): default_proj.clear_all_permissions() # This clears all, including the defaults # Add in any default permissions you'd like at this point - admin_perms = default_proj.create_project_permissions_object_for_group(group_name_or_luid='Administrators', + admin_perms = default_proj.get_permissions_obj(group_name_or_luid='Administrators', role='Project Leader') default_proj.set_permissions_by_permissions_obj_list([admin_perms, ]) - admin_perms = default_proj.create_workbook_permissions_object_for_group(group_name_or_luid='Administrators', + admin_perms = default_proj.workbook_defaults.get_permissions_obj(group_name_or_luid='Administrators', role='Editor') admin_perms.set_capability(capability_name='Download Full Data', mode='Deny') default_proj.workbook_defaults.set_permissions_by_permissions_obj_list([admin_perms, ]) - admin_perms = default_proj.create_datasource_permissions_object_for_group(group_name_or_luid='Administrators', + admin_perms = default_proj.datasource_defaults.get_permissions_obj(group_name_or_luid='Administrators', role='Editor') default_proj.datasource_defaults.set_permissions_by_permissions_obj_list([admin_perms, ]) # Change one of these - new_perms = default_proj.create_project_permissions_object_for_group(group_name_or_luid='Administrators', + new_perms = default_proj.get_permissions_obj(group_name_or_luid='Administrators', role='Publisher') default_proj.set_permissions_by_permissions_obj_list([new_perms, ]) diff --git a/examples/user_sync_sample.py b/examples/user_sync_sample.py index cd61c43..8733f07 100644 --- a/examples/user_sync_sample.py +++ b/examples/user_sync_sample.py @@ -66,8 +66,8 @@ for user in users_dict: proj_obj = t.projects.create_project("My Saved Reports - {}".format(user)) user_luid = users_dict[user] - perms_obj = proj_obj.create_project_permissions_object_for_user(username_or_luid=user_luid, role='Publisher') - proj_obj.set_permissions_by_permissions_obj_list([perms_obj, ]) + perms_obj = proj_obj.get_permissions_obj(username_or_luid=user_luid, role='Publisher') + proj_obj.set_permissions([perms_obj, ]) # Reset back to beginning to reuse query diff --git a/tableau_documents/tableau_datasource.py b/tableau_documents/tableau_datasource.py index 2de475f..ea41684 100644 --- a/tableau_documents/tableau_datasource.py +++ b/tableau_documents/tableau_datasource.py @@ -282,7 +282,7 @@ def published_ds_content_url(self, new_content_url: str): # It seems some databases like Oracle and Teradata need this as well to swap a database def update_tables_with_new_database_or_schema(self, original_db_or_schema: str, new_db_or_schema: str): - for relation in self.table_relations: + for relation in self._tables_relations: if relation.get('type') == "table": relation.set('table', relation.get('table').replace("[{}]".format(original_db_or_schema), "[{}]".format(new_db_or_schema))) diff --git a/tableau_documents/tableau_file.py b/tableau_documents/tableau_file.py index 2bec8dc..7e96bb1 100644 --- a/tableau_documents/tableau_file.py +++ b/tableau_documents/tableau_file.py @@ -578,6 +578,8 @@ def save_new_file(self, new_filename_no_extension: str): return save_filename +# TFL files are actually JSON rather than XML. I don't think there are actually any XML calls except +# possibly when creating the TableauDocument file. class TFL(TableauXmlFile): @property diff --git a/tableau_rest_api/methods/workbook.py b/tableau_rest_api/methods/workbook.py index 1091c96..2bd87bd 100644 --- a/tableau_rest_api/methods/workbook.py +++ b/tableau_rest_api/methods/workbook.py @@ -12,7 +12,7 @@ def __getattr__(self, attr): def get_published_workbook_object(self, workbook_name_or_luid: str, project_name_or_luid: Optional[str] = None) -> Workbook: luid = self.query_workbook_luid(workbook_name_or_luid, project_name_or_luid) - wb_obj = Workbook(luid=luid, tableau_rest_api_obj=self, + wb_obj = Workbook(luid=luid, tableau_rest_api_obj=self.rest_api_base, default=False, logger_obj=self.logger) return wb_obj diff --git a/tableau_rest_api/published_content.py b/tableau_rest_api/published_content.py index 7bbf4b7..782d020 100644 --- a/tableau_rest_api/published_content.py +++ b/tableau_rest_api/published_content.py @@ -3,6 +3,8 @@ import copy from typing import Union, Any, Optional, List, Dict, TYPE_CHECKING +from ..tableau_rest_xml import TableauRestXml + if TYPE_CHECKING: from tableau_tools.logging_methods import LoggingMethods from tableau_tools.logger import Logger @@ -86,7 +88,7 @@ def get_permissions_obj(self, group_name_or_luid: Optional[str] = None, username # Copy Permissions for users or group def _copy_permissions_obj(self, perms_obj, user_or_group, name_or_luid): self.start_log_block() - if self.is_luid(name_or_luid): + if TableauRestXml.is_luid(name_or_luid): luid = name_or_luid else: if user_or_group == 'group': @@ -339,12 +341,7 @@ def are_capabilities_obj_dicts_identical(new_obj_dict: Dict, dest_obj_dict: Dict return False # Dict { capability_name : mode } into XML with checks for validity. Set type to 'workbook' or 'datasource' - def build_capabilities_xml_from_dict(self, capabilities_dict, obj_type): - """ - :type capabilities_dict: dict - :type obj_type: unicode - :return: ET.Element - """ + def build_capabilities_xml_from_dict(self, capabilities_dict: Dict, obj_type: str) -> ET.Element: if obj_type not in self.permissionable_objects: error_text = 'objtype can only be "project", "workbook" or "datasource", was given {}' raise InvalidOptionException(error_text.format('obj_type')) @@ -760,6 +757,7 @@ def get_permissions_obj(self, group_name_or_luid: Optional[str] = None, username role=role) + class Project(PublishedContent): def __init__(self, luid, tableau_rest_api_obj, logger_obj=None, content_xml_obj=None): @@ -782,7 +780,7 @@ def luid(self): @luid.setter def luid(self, name_or_luid): - if self.is_luid(name_or_luid): + if TableauRestXml.is_luid(name_or_luid): luid = name_or_luid else: luid = self.t_rest_api.query_project_luid(name_or_luid) @@ -925,7 +923,6 @@ def create_datasource_permissions_object_for_user(self, username_or_luid: str, return self._get_permissions_object(username_or_luid=username_or_luid, role=role, permissions_class_override=DatasourcePermissions) - @property def workbook_defaults(self) -> Workbook: return self._workbook_defaults diff --git a/tableau_rest_xml.py b/tableau_rest_xml.py new file mode 100644 index 0000000..975922b --- /dev/null +++ b/tableau_rest_xml.py @@ -0,0 +1,93 @@ +# This is intended to be full of static helper methods +import xml.etree.ElementTree as ET +from typing import Union, Optional, List, Dict, Tuple +import re + +class TableauRestXml: + tableau_namespace = 'http://tableau.com/api' + ns_map = {'t': 'http://tableau.com/api'} + ns_prefix = '{' + ns_map['t'] + '}' + # ET.register_namespace('t', ns_map['t']) + # Generic method for XML lists for the "query" actions to name -> id dict + @staticmethod + def convert_xml_list_to_name_id_dict(xml_obj: ET.Element) -> Dict: + d = {} + for element in xml_obj: + e_id = element.get("id") + # If list is collection, have to run one deeper + if e_id is None: + for list_element in element: + e_id = list_element.get("id") + name = list_element.get("name") + d[name] = e_id + else: + name = element.get("name") + d[name] = e_id + return d + + # Repeat of above method with shorter name + @staticmethod + def xml_list_to_dict(xml_obj: ET.Element) -> Dict: + d = {} + for element in xml_obj: + e_id = element.get("id") + # If list is collection, have to run one deeper + if e_id is None: + for list_element in element: + e_id = list_element.get("id") + name = list_element.get("name") + d[name] = e_id + else: + name = element.get("name") + d[name] = e_id + return d + + @staticmethod + def luid_name_dict_from_xml(xml_obj: ET.Element) -> Dict: + d = {} + for element in xml_obj: + e_id = element.get("id") + # If list is collection, have to run one deeper + if e_id is None: + for list_element in element: + e_id = list_element.get("id") + name = list_element.get("name") + d[e_id] = name + else: + name = element.get("name") + d[e_id] = name + return d + + @staticmethod + def luid_content_url_dict_from_xml(xml_obj: ET.Element) -> Dict: + d = {} + for element in xml_obj: + e_id = element.get("id") + # If list is collection, have to run one deeper + if e_id is None: + for list_element in element: + e_id = list_element.get("id") + name = list_element.get("contentUrl") + d[e_id] = name + else: + name = element.get("contentUrl") + d[e_id] = name + return d + + # This corrects for the first element in any response by the plural collection tag, which leads to differences + # with the XPath search currently + @staticmethod + def make_xml_list_iterable(xml_obj: ET.Element) -> List[ET.Element]: + pass + + # 32 hex characters with 4 dashes + @staticmethod + def is_luid(val: str) -> bool: + luid_pattern = r"[0-9a-fA-F]*-[0-9a-fA-F]*-[0-9a-fA-F]*-[0-9a-fA-F]*-[0-9a-fA-F]*" + if len(val) == 36: + if re.match(luid_pattern, val) is not None: + return True + else: + return False + else: + return False \ No newline at end of file From 040affd3b1ccd1100519756fc67c02d6a8bd1c40 Mon Sep 17 00:00:00 2001 From: Bryant Howell Date: Wed, 11 Dec 2019 14:10:03 -0600 Subject: [PATCH 04/14] Some changes to make the conversion methods static. Can't really find where they are used though --- tableau_rest_api/published_content.py | 166 ++++++++++++-------------- 1 file changed, 79 insertions(+), 87 deletions(-) diff --git a/tableau_rest_api/published_content.py b/tableau_rest_api/published_content.py index 782d020..11d6340 100644 --- a/tableau_rest_api/published_content.py +++ b/tableau_rest_api/published_content.py @@ -388,7 +388,8 @@ def _build_add_permissions_request(self, permissions_obj: 'Permissions') -> ET.E return tsr # Template stub - def convert_capabilities_xml_into_obj_list(self, xml_obj: ET.Element) -> List['Permissions']: + @staticmethod + def convert_capabilities_xml_into_obj_list(xml_obj: ET.Element) -> List['Permissions']: pass def get_permissions_from_server(self, obj_perms_xml: Optional[ET.Element] = None) -> List['Permissions']: @@ -562,33 +563,34 @@ def get_permissions_obj(self, group_name_or_luid: Optional[str] = None, username return self._get_permissions_object(group_name_or_luid=group_name_or_luid, username_or_luid=username_or_luid, role=role) - def convert_capabilities_xml_into_obj_list(self, xml_obj: ET.Element) -> List['WorkbookPermissions']: + @staticmethod + def convert_capabilities_xml_into_obj_list(xml_obj: ET.Element) -> List['WorkbookPermissions']: - self.start_log_block() + #self.start_log_block() obj_list = [] - xml = xml_obj.findall('.//t:granteeCapabilities', self.t_rest_api.ns_map) + xml = xml_obj.findall('.//t:granteeCapabilities', TableauRestXml.ns_map) if len(xml) == 0: - self.end_log_block() + # self.end_log_block() return [] else: for gcaps in xml: for tags in gcaps: # Namespace fun - if tags.tag == '{}group'.format(self.t_rest_api.ns_prefix): + if tags.tag == '{}group'.format(TableauRestXml.ns_prefix): luid = tags.get('id') perms_obj = WorkbookPermissions('group', luid) - self.log_debug('group {}'.format(luid)) - elif tags.tag == '{}user'.format(self.t_rest_api.ns_prefix): + # self.log_debug('group {}'.format(luid)) + elif tags.tag == '{}user'.format(TableauRestXml.ns_prefix): luid = tags.get('id') perms_obj = WorkbookPermissions('user', luid) - self.log_debug('user {}'.format(luid)) - elif tags.tag == '{}capabilities'.format(self.t_rest_api.ns_prefix): + # self.log_debug('user {}'.format(luid)) + elif tags.tag == '{}capabilities'.format(TableauRestXml.ns_prefix): for caps in tags: - self.log_debug(caps.get('name') + ' : ' + caps.get('mode')) + # self.log_debug(caps.get('name') + ' : ' + caps.get('mode')) perms_obj.set_capability(caps.get('name'), caps.get('mode')) obj_list.append(perms_obj) - self.log('Permissions object list has {} items'.format(str(len(obj_list)))) - self.end_log_block() + #self.log('Permissions object list has {} items'.format(str(len(obj_list)))) + # self.end_log_block() return obj_list class Workbook28(Workbook): @@ -627,32 +629,33 @@ def get_permissions_obj(self, group_name_or_luid: Optional[str] = None, username return self._get_permissions_object(group_name_or_luid=group_name_or_luid, username_or_luid=username_or_luid, role=role) - def convert_capabilities_xml_into_obj_list(self, xml_obj: ET.Element) -> List['DatasourcePermissions']: - self.start_log_block() + @staticmethod + def convert_capabilities_xml_into_obj_list(xml_obj: ET.Element) -> List['DatasourcePermissions']: + #self.start_log_block() obj_list = [] - xml = xml_obj.findall('.//t:granteeCapabilities', self.t_rest_api.ns_map) + xml = xml_obj.findall('.//t:granteeCapabilities', TableauRestXml.ns_map) if len(xml) == 0: - self.end_log_block() + # self.end_log_block() return [] else: for gcaps in xml: for tags in gcaps: # Namespace fun - if tags.tag == '{}group'.format(self.t_rest_api.ns_prefix): + if tags.tag == '{}group'.format(TableauRestXml.ns_prefix): luid = tags.get('id') perms_obj = DatasourcePermissions('group', luid) - self.log_debug('group {}'.format(luid)) - elif tags.tag == '{}user'.format(self.t_rest_api.ns_prefix): + #self.log_debug('group {}'.format(luid)) + elif tags.tag == '{}user'.format(TableauRestXml.ns_prefix): luid = tags.get('id') perms_obj = DatasourcePermissions('user', luid) - self.log_debug('user {}'.format(luid)) - elif tags.tag == '{}capabilities'.format(self.t_rest_api.ns_prefix): + #self.log_debug('user {}'.format(luid)) + elif tags.tag == '{}capabilities'.format(TableauRestXml.ns_prefix): for caps in tags: - self.log_debug(caps.get('name') + ' : ' + caps.get('mode')) + #self.log_debug(caps.get('name') + ' : ' + caps.get('mode')) perms_obj.set_capability(caps.get('name'), caps.get('mode')) obj_list.append(perms_obj) - self.log('Permissions object list has {} items'.format(str(len(obj_list)))) - self.end_log_block() + #self.log('Permissions object list has {} items'.format(str(len(obj_list)))) + # self.end_log_block() return obj_list class Datasource28(Datasource): @@ -685,32 +688,33 @@ def luid(self, luid: str): # Maybe implement a search at some point self._luid = luid - def convert_capabilities_xml_into_obj_list(self, xml_obj: ET.Element) -> List['WorkbookPermissions']: - self.start_log_block() + @staticmethod + def convert_capabilities_xml_into_obj_list(xml_obj: ET.Element) -> List['WorkbookPermissions']: + #self.start_log_block() obj_list = [] - xml = xml_obj.findall('.//t:granteeCapabilities', self.t_rest_api.ns_map) + xml = xml_obj.findall('.//t:granteeCapabilities', TableauRestXml.ns_map) if len(xml) == 0: - self.end_log_block() + #self.end_log_block() return [] else: for gcaps in xml: for tags in gcaps: # Namespace fun - if tags.tag == '{}group'.format(self.t_rest_api.ns_prefix): + if tags.tag == '{}group'.format(TableauRestXml.ns_prefix): luid = tags.get('id') perms_obj = WorkbookPermissions('group', luid) - self.log_debug('group {}'.format(luid)) - elif tags.tag == '{}user'.format(self.t_rest_api.ns_prefix): + #self.log_debug('group {}'.format(luid)) + elif tags.tag == '{}user'.format(TableauRestXml.ns_prefix): luid = tags.get('id') perms_obj = WorkbookPermissions('user', luid) - self.log_debug('user {}'.format(luid)) - elif tags.tag == '{}capabilities'.format(self.t_rest_api.ns_prefix): + #self.log_debug('user {}'.format(luid)) + elif tags.tag == '{}capabilities'.format(TableauRestXml.ns_prefix): for caps in tags: - self.log_debug(caps.get('name') + ' : ' + caps.get('mode')) + #self.log_debug(caps.get('name') + ' : ' + caps.get('mode')) perms_obj.set_capability(caps.get('name'), caps.get('mode')) obj_list.append(perms_obj) - self.log('Permissions object list has {} items'.format(str(len(obj_list)))) - self.end_log_block() + #self.log('Permissions object list has {} items'.format(str(len(obj_list)))) + #self.end_log_block() return obj_list @@ -792,39 +796,41 @@ def get_permissions_obj(self, group_name_or_luid: Optional[str] = None, username role=role) # Simpler synonym - def convert_xml_into_permissions_list(self, xml_obj: ET.Element) -> List['ProjectPermissions']: - return self.convert_capabilities_xml_into_obj_list(xml_obj=xml_obj) + @staticmethod + def convert_xml_into_permissions_list(xml_obj: ET.Element) -> List['ProjectPermissions']: + return Project.convert_capabilities_xml_into_obj_list(xml_obj=xml_obj) # Available for legacy - def convert_capabilities_xml_into_obj_list(self, xml_obj: ET.Element) -> List['ProjectPermissions']: - self.start_log_block() + @staticmethod + def convert_capabilities_xml_into_obj_list(xml_obj: ET.Element) -> List['ProjectPermissions']: + #self.start_log_block() obj_list = [] - xml = xml_obj.findall('.//t:granteeCapabilities', self.t_rest_api.ns_map) + xml = xml_obj.findall('.//t:granteeCapabilities', TableauRestXml.ns_map) if len(xml) == 0: - self.end_log_block() + #self.end_log_block() return [] else: for gcaps in xml: for tags in gcaps: # Namespace fun - if tags.tag == '{}group'.format(self.t_rest_api.ns_prefix): + if tags.tag == '{}group'.format(TableauRestXml.ns_prefix): luid = tags.get('id') perms_obj = ProjectPermissions('group', luid) - self.log_debug('group {}'.format(luid)) - elif tags.tag == '{}user'.format(self.t_rest_api.ns_prefix): + # self.log_debug('group {}'.format(luid)) + elif tags.tag == '{}user'.format(TableauRestXml.ns_prefix): luid = tags.get('id') perms_obj = ProjectPermissions('user', luid) - self.log_debug('user {}'.format(luid)) - elif tags.tag == '{}capabilities'.format(self.t_rest_api.ns_prefix): + # self.log_debug('user {}'.format(luid)) + elif tags.tag == '{}capabilities'.format(TableauRestXml.ns_prefix): for caps in tags: - self.log_debug(caps.get('name') + ' : ' + caps.get('mode')) + # self.log_debug(caps.get('name') + ' : ' + caps.get('mode')) perms_obj.set_capability(caps.get('name'), caps.get('mode')) obj_list.append(perms_obj) - self.log('Permissions object list has {} items'.format(str(len(obj_list)))) - self.end_log_block() + # self.log('Permissions object list has {} items'.format(str(len(obj_list)))) + #self.end_log_block() return obj_list - def replicate_permissions(self, orig_content: 'PublishedContent'): + def replicate_permissions(self, orig_content: 'Project'): self.start_log_block() self.clear_all_permissions() @@ -849,12 +855,7 @@ def replicate_permissions(self, orig_content: 'PublishedContent'): self.end_log_block() - def replicate_permissions_direct_xml(self, orig_content, username_map=None): - """ - :type orig_content: Project - :type username_map: dict[unicode, unicode] - :return: - """ + def replicate_permissions_direct_xml(self, orig_content: 'Project', username_map: Optional[Dict] = None): self.start_log_block() self.clear_all_permissions() @@ -940,21 +941,13 @@ def clear_all_permissions(self, clear_defaults: bool = True): self.datasource_defaults.clear_all_permissions() self.end_log_block() - def are_permissions_locked(self): - """ - :return: bool - """ + def are_permissions_locked(self) -> bool: proj = self.xml_obj locked_permissions = proj.get('contentPermissions') - if locked_permissions == 'ManagedByOwner': - return False - if locked_permissions == 'LockedToProject': - return True + mapping = {'ManagedByOwner' : False, 'LockedToProject': True} + return mapping[locked_permissions] def lock_permissions(self): - """ - :return: - """ self.start_log_block() if self.permissions_locked is False: if(isinstance(self.t_rest_api, TableauRestApiConnection)): @@ -964,9 +957,6 @@ def lock_permissions(self): self.end_log_block() def unlock_permissions(self): - """ - :return: - """ self.start_log_block() if self.permissions_locked is True: if(isinstance(self.t_rest_api, TableauRestApiConnection)): @@ -976,7 +966,8 @@ def unlock_permissions(self): self.end_log_block() - def query_all_permissions(self): + # These are speciality methods just for exporting everything out for audit + def query_all_permissions(self) -> Dict: # Returns all_permissions[luid] = { name: , type: , project_caps, workbook_default_caps: , # datasource_default_caps: } @@ -1014,7 +1005,7 @@ def query_all_permissions(self): return all_permissions # Exports all of the permissions on a project in the order displayed in Tableau Server - def convert_all_permissions_to_list(self, all_permissions): + def convert_all_permissions_to_list(self, all_permissions: Dict): final_list = [] # Project @@ -1051,7 +1042,7 @@ def get_permissions_obj(self, group_name_or_luid: Optional[str] = None, username return self._get_permissions_object(group_name_or_luid=group_name_or_luid, username_or_luid=username_or_luid, role=role) @property - def parent_project_luid(self): + def parent_project_luid(self) -> str: return self._parent_project_luid def query_child_projects(self) -> ET.Element: @@ -1066,32 +1057,33 @@ def query_child_projects(self) -> ET.Element: self.end_log_block() return child_projects - def convert_capabilities_xml_into_obj_list(self, xml_obj: ET.Element) -> List['ProjectPermissions']: - self.start_log_block() + @staticmethod + def convert_capabilities_xml_into_obj_list(xml_obj: ET.Element) -> List['ProjectPermissions']: + # self.start_log_block() obj_list = [] - xml = xml_obj.findall('.//t:granteeCapabilities', self.t_rest_api.ns_map) + xml = xml_obj.findall('.//t:granteeCapabilities', TableauRestXml.ns_map) if len(xml) == 0: - self.end_log_block() + # self.end_log_block() return [] else: for gcaps in xml: for tags in gcaps: # Namespace fun - if tags.tag == '{}group'.format(self.t_rest_api.ns_prefix): + if tags.tag == '{}group'.format(TableauRestXml.ns_prefix): luid = tags.get('id') perms_obj = ProjectPermissions28('group', luid) - self.log_debug('group {}'.format(luid)) - elif tags.tag == '{}user'.format(self.t_rest_api.ns_prefix): + # self.log_debug('group {}'.format(luid)) + elif tags.tag == '{}user'.format(TableauRestXml.ns_prefix): luid = tags.get('id') perms_obj = ProjectPermissions28('user', luid) - self.log_debug('user {}'.format(luid)) - elif tags.tag == '{}capabilities'.format(self.t_rest_api.ns_prefix): + # self.log_debug('user {}'.format(luid)) + elif tags.tag == '{}capabilities'.format(TableauRestXml.ns_prefix): for caps in tags: - self.log_debug(caps.get('name') + ' : ' + caps.get('mode')) + # self.log_debug(caps.get('name') + ' : ' + caps.get('mode')) perms_obj._set_capability_from_published_content(caps.get('name'), caps.get('mode')) obj_list.append(perms_obj) - self.log('Permissions object list has {} items'.format(str(len(obj_list)))) - self.end_log_block() + # self.log('Permissions object list has {} items'.format(str(len(obj_list)))) + # self.end_log_block() return obj_list # There are all legacy for compatibility purposes From 34b35ccc3957e31f2a610e9d6b6f56d530a6c7a6 Mon Sep 17 00:00:00 2001 From: Bryant Howell Date: Wed, 11 Dec 2019 14:51:50 -0600 Subject: [PATCH 05/14] This will be 5.1 because of new objects and logger improvements (and who knows what else) --- setup.py | 2 +- tableau_documents/table_relations.py | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index a0bbebc..9b6ca90 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name='tableau_tools', python_requires='>=3.6', - version='5.0.7', + version='5.1.0', packages=['tableau_tools', 'tableau_tools.tableau_rest_api', 'tableau_tools.tableau_documents', 'tableau_tools.examples', 'tableau_tools.tableau_rest_api.methods'], url='https://github.com/bryantbhowell/tableau_tools', diff --git a/tableau_documents/table_relations.py b/tableau_documents/table_relations.py index c4b998a..c1ea942 100644 --- a/tableau_documents/table_relations.py +++ b/tableau_documents/table_relations.py @@ -3,8 +3,10 @@ import random from xml.sax.saxutils import quoteattr, unescape import copy +import datetime -from tableau_tools.tableau_exceptions import * +from ..tableau_exceptions import * +from ..tableau_rest_xml import TableauRestXml # This represents the classic Tableau data connection window relations # Allows for changes in JOINs, Stored Proc values, and Custom SQL @@ -17,8 +19,8 @@ def __init__(self, relation_xml_obj: ET.Element): self.main_table: ET.Element self.table_relations: List[ET.Element] self.join_relations = [] - self.ns_map = {"user": 'http://www.tableausoftware.com/xml/user', 't': 'http://tableau.com/api'} - ET.register_namespace('t', self.ns_map['t']) + #self.ns_map = {"user": 'http://www.tableausoftware.com/xml/user', 't': 'http://tableau.com/api'} + #ET.register_namespace('t', self.ns_map['t']) self._read_existing_relations() def _read_existing_relations(self): @@ -29,7 +31,7 @@ def _read_existing_relations(self): self.table_relations = [self.relation_xml_obj, ] else: - table_relations = self.relation_xml_obj.findall('.//relation', self.ns_map) + table_relations = self.relation_xml_obj.findall('.//relation', TableauRestXml.ns_map) final_table_relations = [] # ElementTree doesn't implement the != operator, so have to find all then iterate through to exclude # the JOINs to only get the tables, stored-procs and Custom SQLs @@ -118,7 +120,7 @@ def set_stored_proc_parameter_value_by_name(self, parameter_name: str, parameter if self._stored_proc_parameters_xml is None: self._stored_proc_parameters_xml = ET.Element('actual-parameters') # Find parameter with that name (if exists) - param = self._stored_proc_parameters_xml.find('.//column[@name="{}"]'.format(parameter_name), self.ns_map) + param = self._stored_proc_parameters_xml.find('.//column[@name="{}"]'.format(parameter_name), TableauRestXml.ns_map) if param is None: # create_stored... already converts to correct quoting From 26219139772cdd4fc25dfce47d1eb8a414652b4f Mon Sep 17 00:00:00 2001 From: Bryant Howell Date: Thu, 12 Dec 2019 09:35:49 -0600 Subject: [PATCH 06/14] Significant update to logger --- logger.py | 49 +++++++--- logging_methods.py | 18 ++-- tableau_rest_api/methods/_lookups.py | 20 +++-- tableau_rest_api/methods/datasource.py | 5 +- tableau_rest_api/methods/rest_api_base.py | 104 +--------------------- tableau_rest_api/rest_xml_request.py | 31 +++---- tableau_rest_xml.py | 17 +++- 7 files changed, 97 insertions(+), 147 deletions(-) diff --git a/logger.py b/logger.py index 323ea70..503898b 100644 --- a/logger.py +++ b/logger.py @@ -3,6 +3,11 @@ import xml.etree.ElementTree as ET from typing import Union, Any, Optional, List, Dict, Tuple +# Logger has several modes +# Default just shows REST URL requests +# If you "enable_request_xml_logging", then it will show the full XML of the request +# If you "enable_debugging mode", then the log will indent to show which calls are wrapped within another +# "enabled_response_logging" will log the response class Logger(object): def __init__(self, filename): self._log_level = 'standard' @@ -13,9 +18,17 @@ def __init__(self, filename): except IOError: print("Error: File '{}' cannot be opened to write for logging".format(filename)) raise + self._log_modes = {'debug': False, 'response': False, 'request': False} def enable_debug_level(self): self._log_level = 'debug' + self._log_modes['debug'] = True + + def enable_request_logging(self): + self._log_modes['request'] = True + + def enable_response_logging(self): + self._log_modes['response'] = True def log(self, l: str): cur_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) @@ -29,7 +42,7 @@ def log(self, l: str): self.__log_handle.write(log_line) def log_debug(self, l: str): - if self._log_level == 'debug': + if self._log_modes['debug'] is True: self.log(l) def start_log_block(self): @@ -41,7 +54,9 @@ def start_log_block(self): cur_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()).encode('utf-8') log_line = '{}vv-- {} : {} {} started --------vv\n'.format(" "*self.log_depth, cur_time, short_class, caller_function_name) - self.log_depth += 2 + # Only move the log depth in debug mode + if self._log_modes['debug'] is True: + self.log_depth += 2 self.__log_handle.write(log_line.encode('utf-8')) def end_log_block(self): @@ -51,22 +66,30 @@ def end_log_block(self): short_class = class_path[len(class_path)-1] short_class = short_class[:-2] cur_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()).encode('utf-8') - self.log_depth -= 2 + if self._log_modes['debug'] is True: + self.log_depth -= 2 log_line = '{}^^-- {} : {} {} ended --------^^\n'.format(" "*self.log_depth, cur_time, short_class, caller_function_name) self.__log_handle.write(log_line.encode('utf-8')) def log_uri(self, uri: str, verb: str): - self.log('Sending {} request via: \n{}'.format(verb, uri)) + self.log('[{}] {}'.format(verb.upper(), uri)) - def log_xml_request(self, xml: ET.Element, verb: str): - if isinstance(xml, str): - self.log('Sending {} request with XML: \n{}'.format(verb, xml)) + def log_xml_request(self, xml: Union[ET.Element, str], verb: str, uri: str): + if self._log_modes['request'] is True: + if isinstance(xml, str): + self.log('[{}}] \n{}'.format(verb.upper(), xml)) + else: + self.log('[{}}] \n{}'.format(verb.upper(), ET.tostring(xml))) else: - self.log('Sending {} request with XML: \n{}'.format(verb, ET.tostring(xml))) + self.log('[{}] {}'.format(verb.upper(), uri)) - def log_xml_response(self, xml: ET.Element): - if isinstance(xml, str): - self.log('Received response with XML: \n{}'.format(xml)) - else: - self.log('Received response with XML: \n{}'.format(ET.tostring(xml))) + def log_xml_response(self, xml: Union[str, ET.Element]): + if self._log_modes['response'] is True: + if isinstance(xml, str): + self.log('[XML Response] \n{}'.format(xml)) + else: + self.log('[XML Response] \n{}'.format(ET.tostring(xml))) + + def log_error(self, error_text: str): + self.log('[ERROR] {}'.format(error_text)) \ No newline at end of file diff --git a/logging_methods.py b/logging_methods.py index 5c2bb4e..6fa51c1 100644 --- a/logging_methods.py +++ b/logging_methods.py @@ -1,7 +1,7 @@ -from typing import Optional, List, Dict +from typing import Optional, List, Dict, Union import xml.etree.ElementTree as ET -from tableau_tools.logger import Logger +from .logger import Logger class LoggingMethods: # Logging Methods @@ -26,8 +26,16 @@ def end_log_block(self): def log_uri(self, uri: str, verb: str): if self.logger is not None: - self.logger.log_uri(verb, uri) + self.logger.log_uri(uri=uri, verb=verb) - def log_xml_request(self, xml: ET.Element, verb: str): + def log_xml_request(self, xml: ET.Element, verb: str, uri: str): if self.logger is not None: - self.logger.log_xml_request(verb, xml) \ No newline at end of file + self.logger.log_xml_request(xml=xml, verb=verb, uri=uri) + + def log_xml_response(self, xml: Union[str, ET.Element]): + if self.logger is not None: + self.logger.log_xml_response(xml=xml) + + def log_error(self, error_text: str): + if self.logger is not None: + self.logger.log_error(error_text=error_text) \ No newline at end of file diff --git a/tableau_rest_api/methods/_lookups.py b/tableau_rest_api/methods/_lookups.py index 22ec6fc..5361756 100644 --- a/tableau_rest_api/methods/_lookups.py +++ b/tableau_rest_api/methods/_lookups.py @@ -1,5 +1,7 @@ #from tableau_rest_api.methods.rest_api_base import * from typing import Union, Optional +from ...tableau_rest_xml import TableauRestXml +from ...tableau_exceptions import * # These find LUIDs from real names or other aspects. They get added to the RestApiBase class because methods on # almost any different object might need a LUID from any of the others class LookupMethods(): @@ -35,7 +37,7 @@ def query_datasource_luid(self, datasource_name: str, project_name_or_luid: Opti # Search for ContentUrl which should be unique, return if content_url is not None: datasources_with_content_url = datasources_with_name.findall( - './/t:datasource[@contentUrl="{}"]'.format(content_url), self.ns_map) + './/t:datasource[@contentUrl="{}"]'.format(content_url), TableauRestXml.ns_map) self.end_log_block() if len(datasources_with_name) == 1: return datasources_with_content_url[0].get("id") @@ -58,11 +60,11 @@ def query_datasource_luid(self, datasource_name: str, project_name_or_luid: Opti else: if self.is_luid(project_name_or_luid): ds_in_proj = datasources_with_name.findall('.//t:project[@id="{}"]/..'.format(project_name_or_luid), - self.ns_map) + TableauRestXml.ns_map) else: ds_in_proj = datasources_with_name.findall( './/t:project[@name="{}"]/..'.format(project_name_or_luid), - self.ns_map) + TableauRestXml.ns_map) if len(ds_in_proj) == 1: self.end_log_block() return ds_in_proj[0].get("id") @@ -123,9 +125,9 @@ def query_workbook_view_luid(self, wb_name_or_luid: str, view_name: Optional[str wb_luid = self.query_workbook_luid(wb_name_or_luid, proj_name_or_luid) vws = self.query_resource("workbooks/{}/views?includeUsageStatistics={}".format(wb_luid, str(usage).lower())) if view_content_url is not None: - views_with_name = vws.findall('.//t:view[@contentUrl="{}"]'.format(view_content_url), self.ns_map) + views_with_name = vws.findall('.//t:view[@contentUrl="{}"]'.format(view_content_url), TableauRestXml.ns_map) else: - views_with_name = vws.findall('.//t:view[@name="{}"]'.format(view_name), self.ns_map) + views_with_name = vws.findall('.//t:view[@name="{}"]'.format(view_name), TableauRestXml.ns_map) if len(views_with_name) == 0: self.end_log_block() raise NoMatchFoundException('No view found with name {} or content_url {} in workbook {}'.format(view_name, view_content_url, wb_name_or_luid)) @@ -153,11 +155,11 @@ def query_workbook_luid(self, wb_name: str, proj_name_or_luid: Optional[str] = N elif len(workbooks_with_name) > 1 and proj_name_or_luid is not None: if self.is_luid(proj_name_or_luid): wb_in_proj = workbooks_with_name.findall('.//t:project[@id="{}"]/..'.format(proj_name_or_luid), - self.ns_map) + TableauRestXml.ns_map) else: wb_in_proj = workbooks_with_name.findall( './/t:project[@name="{}"]/..'.format(proj_name_or_luid), - self.ns_map) + TableauRestXml.ns_map) if len(wb_in_proj) == 0: self.end_log_block() raise NoMatchFoundException('No workbook found with name {} in project {}'.format(wb_name, proj_name_or_luid)) @@ -174,7 +176,7 @@ def query_database_luid(self, database_name: str) -> str: if self.is_luid(database_name): return database_name databases = self.query_resource("databases") - databases_with_name = databases.findall('.//t:database[@name="{}"]'.format(database_name), self.ns_map) + databases_with_name = databases.findall('.//t:database[@name="{}"]'.format(database_name), TableauRestXml.ns_map) if len(databases_with_name) == 0: self.end_log_block() raise NoMatchFoundException( @@ -194,7 +196,7 @@ def query_table_luid(self, table_name: str) -> str: if self.is_luid(table_name): return table_name tables = self.query_resource("tables") - tables_with_name = tables.findall('.//t:table[@name="{}"]'.format(table_name), self.ns_map) + tables_with_name = tables.findall('.//t:table[@name="{}"]'.format(table_name), TableauRestXml.ns_map) if len(tables_with_name) == 0: self.end_log_block() raise NoMatchFoundException( diff --git a/tableau_rest_api/methods/datasource.py b/tableau_rest_api/methods/datasource.py index fe8d903..2b25f35 100644 --- a/tableau_rest_api/methods/datasource.py +++ b/tableau_rest_api/methods/datasource.py @@ -1,5 +1,6 @@ from .rest_api_base import * from ..published_content import Datasource, Datasource28 +from ...tableau_rest_xml import TableauRestXml class DatasourceMethods(): def __init__(self, rest_api_base: TableauRestApiBase): @@ -34,7 +35,7 @@ def query_datasources(self, project_name_or_luid: Optional[str] = None, all_fiel # If there is a project filter if project_name_or_luid is not None: project_luid = self.query_project_luid(project_name_or_luid) - dses_in_project = datasources.findall('.//t:project[@id="{}"]/..'.format(project_luid), self.ns_map) + dses_in_project = datasources.findall('.//t:project[@id="{}"]/..'.format(project_luid), TableauRestXml.ns_map) dses = ET.Element(self.ns_prefix + 'datasources') for ds in dses_in_project: dses.append(ds) @@ -181,7 +182,7 @@ def publish_datasource(self, ds_filename: str, ds_name: str, project_obj: Projec project_luid = project_obj.luid xml = self._publish_content('datasource', ds_filename, ds_name, project_luid, {"overwrite": overwrite}, connection_username, connection_password, save_credentials, oauth_flag=oauth_flag) - datasource = xml.findall('.//t:datasource', self.ns_map) + datasource = xml.findall('.//t:datasource', TableauRestXml.ns_map) return datasource[0].get('id') # diff --git a/tableau_rest_api/methods/rest_api_base.py b/tableau_rest_api/methods/rest_api_base.py index d03424b..df6bcf6 100644 --- a/tableau_rest_api/methods/rest_api_base.py +++ b/tableau_rest_api/methods/rest_api_base.py @@ -18,9 +18,9 @@ from tableau_tools.tableau_rest_api.published_content import Project, Project28, Project33, Workbook, Datasource, Flow33 from tableau_tools.tableau_rest_api.url_filter import * from tableau_tools.tableau_rest_api.sort import * +from ...tableau_rest_xml import TableauRestXml - -class TableauRestApiBase(LookupMethods, LoggingMethods): +class TableauRestApiBase(LookupMethods, LoggingMethods, TableauRestXml): # Defines a class that represents a RESTful connection to Tableau Server. Use full URL (http:// or https://) def __init__(self, server: str, username: str, password: str, site_content_url: Optional[str] = ""): if server.find('http') == -1: @@ -82,83 +82,7 @@ def __init__(self, server: str, username: str, password: str, site_content_url: u'SiteAdministratorCreator' ) - # URI is different form actual URL you need to load a particular view in iframe - @staticmethod - def convert_view_content_url_to_embed_url(content_url: str) -> str: - split_url = content_url.split('/') - return 'views/{}/{}'.format(split_url[0], split_url[2]) - # Generic method for XML lists for the "query" actions to name -> id dict - @staticmethod - def convert_xml_list_to_name_id_dict(xml_obj: ET.Element) -> Dict: - d = {} - for element in xml_obj: - e_id = element.get("id") - # If list is collection, have to run one deeper - if e_id is None: - for list_element in element: - e_id = list_element.get("id") - name = list_element.get("name") - d[name] = e_id - else: - name = element.get("name") - d[name] = e_id - return d - - # Repeat of above method with shorter name - @staticmethod - def xml_list_to_dict(xml_obj: ET.Element) -> Dict: - d = {} - for element in xml_obj: - e_id = element.get("id") - # If list is collection, have to run one deeper - if e_id is None: - for list_element in element: - e_id = list_element.get("id") - name = list_element.get("name") - d[name] = e_id - else: - name = element.get("name") - d[name] = e_id - return d - - @staticmethod - def luid_name_dict_from_xml(xml_obj: ET.Element) -> Dict: - d = {} - for element in xml_obj: - e_id = element.get("id") - # If list is collection, have to run one deeper - if e_id is None: - for list_element in element: - e_id = list_element.get("id") - name = list_element.get("name") - d[e_id] = name - else: - name = element.get("name") - d[e_id] = name - return d - - @staticmethod - def luid_content_url_dict_from_xml(xml_obj: ET.Element) -> Dict: - d = {} - for element in xml_obj: - e_id = element.get("id") - # If list is collection, have to run one deeper - if e_id is None: - for list_element in element: - e_id = list_element.get("id") - name = list_element.get("contentUrl") - d[e_id] = name - else: - name = element.get("contentUrl") - d[e_id] = name - return d - - # This corrects for the first element in any response by the plural collection tag, which leads to differences - # with the XPath search currently - @staticmethod - def make_xml_list_iterable(xml_obj: ET.Element) -> List[ET.Element]: - pass def set_tableau_server_version(self, tableau_server_version: str) -> str: if str(tableau_server_version)in ["10.3", "10.4", "10.5", '2018.1', '2018.2', '2018.3', '2019.1', @@ -212,25 +136,7 @@ def read_file_in_chunks(file_object, chunk_size=(1024 * 1024 * 10)): break yield data - # You must generate a boundary string that is used both in the headers and the generated request that you post. - # This builds a simple 30 hex digit string - @staticmethod - def generate_boundary_string() -> str: - random_digits = [random.SystemRandom().choice('0123456789abcdef') for n in range(30)] - s = "".join(random_digits) - return s - # 32 hex characters with 4 dashes - @staticmethod - def is_luid(val: str) -> bool: - luid_pattern = r"[0-9a-fA-F]*-[0-9a-fA-F]*-[0-9a-fA-F]*-[0-9a-fA-F]*-[0-9a-fA-F]*" - if len(val) == 36: - if re.match(luid_pattern, val) is not None: - return True - else: - return False - else: - return False @property def token(self) -> str: @@ -347,12 +253,6 @@ def __build_connection_update_xml(new_server_address: Optional[str] = None, tsr.append(c) return tsr - # - # Factory methods for PublishedContent and Permissions objects - # - - - # # Sign-in and Sign-out # diff --git a/tableau_rest_api/rest_xml_request.py b/tableau_rest_api/rest_xml_request.py index 47f649a..2bcbbca 100644 --- a/tableau_rest_api/rest_xml_request.py +++ b/tableau_rest_api/rest_xml_request.py @@ -1,6 +1,3 @@ -from tableau_tools.logging_methods import LoggingMethods -from tableau_tools.tableau_exceptions import * -from tableau_tools.logger import Logger import xml.etree.ElementTree as ET # from HTMLParser import HTMLParser # from StringIO import StringIO @@ -12,6 +9,9 @@ import sys from typing import Union, Any, Optional, List, Dict, Tuple +from ..logging_methods import LoggingMethods +from ..tableau_exceptions import * +from ..logger import Logger # Handles all of the actual HTTP calling class RestXmlRequest(LoggingMethods): @@ -137,13 +137,12 @@ def __make_request(self, page_number:int = 1): if self.__publish is True: request_headers['Content-Type'] = 'multipart/mixed; boundary={}'.format(self.__boundary_string) - # Need to handle binary return for image somehow - self.log("Request {} {}".format(self._http_verb.upper(), url)) - # Log the XML request being sent encoded_request = "" + if self.xml_request is None: + self.log_uri(verb=self._http_verb.upper(), uri=url) if self.xml_request is not None: - self.log("Request XML: {}".format(ET.tostring(self.xml_request, encoding='utf-8').decode('utf-8'))) + self.log_xml_request(xml=self.xml_request, verb=self.http_verb, uri=url) if isinstance(self.xml_request, str): encoded_request = self.xml_request.encode('utf-8') else: @@ -200,8 +199,8 @@ def _handle_http_error(self, response, e): raise e # REST API returns 400 type errors that can be recovered from, so handle them raw_error_response = response.content - self.log("Received a {} error, here was response:".format(str(status_code))) - self.log(raw_error_response.decode('utf8')) + self.log_error("{} error. Full response:".format(str(status_code))) + self.log_error(raw_error_response.decode('utf8')) utf8_parser = ET.XMLParser(encoding='utf-8') xml = ET.parse(BytesIO(raw_error_response), parser=utf8_parser) @@ -222,7 +221,7 @@ def _handle_http_error(self, response, e): detail_luid = detail_luid_match_obj.group(0) else: detail_luid = False - self.log('Tableau REST API error code is: {}'.format(error_code)) + self.log_error('Tableau REST API error code: {}'.format(error_code)) # If you are not signed in if error_code == '401000': raise NotSignedInException('401000 error, no session token was provided. Please sign in again.') @@ -240,7 +239,8 @@ def _handle_http_error(self, response, e): # Invalid Hyper Extract publish does this elif status_code == 400 and self._http_verb == 'post': if error_code == '400011': - raise PossibleInvalidPublishException(http_code=400, error_code='400011', msg="400011 on a Publish of a .hyper file could caused when the Hyper file either more than one table or the single table is not named 'Extract'.") + raise PossibleInvalidPublishException(http_code=400, tableau_error_code='400011', + msg="400011 on a Publish of a .hyper file could caused when the Hyper file either more than one table or the single table is not named 'Extract'.") else: raise e @@ -249,8 +249,9 @@ def _set_raw_response(self, unicode_raw_response): self.__raw_response = unicode_raw_response unicode_raw_response = unicode_raw_response.decode('utf-8') + # Shows each individual request if self.__response_type == 'xml': - self.log_debug("Raw Response: {}".format(unicode_raw_response)) + self.log_xml_response(format(unicode_raw_response)) # This has always brought back ALL listings from long paginated lists # But really should support three behaviors: @@ -296,9 +297,9 @@ def request_from_api(self, page_number: int = 1): combined_xml_obj.append(e) self.__xml_object = combined_xml_obj - self.log_debug("Logging the combined xml object") - self.log_debug(ET.tostring(self.__xml_object, encoding='utf-8').decode('utf-8')) - self.log("Request succeeded") + self.log_xml_response("Combined XML Response") + self.log_xml_response(ET.tostring(self.__xml_object, encoding='utf-8').decode('utf-8')) + # self.log("Request succeeded") return True elif self.__response_type in ['binary', 'png', 'csv']: self.log('Non XML response') diff --git a/tableau_rest_xml.py b/tableau_rest_xml.py index 975922b..942c49f 100644 --- a/tableau_rest_xml.py +++ b/tableau_rest_xml.py @@ -1,5 +1,6 @@ # This is intended to be full of static helper methods import xml.etree.ElementTree as ET +import random from typing import Union, Optional, List, Dict, Tuple import re @@ -90,4 +91,18 @@ def is_luid(val: str) -> bool: else: return False else: - return False \ No newline at end of file + return False + + # URI is different form actual URL you need to load a particular view in iframe + @staticmethod + def convert_view_content_url_to_embed_url(content_url: str) -> str: + split_url = content_url.split('/') + return 'views/{}/{}'.format(split_url[0], split_url[2]) + + # You must generate a boundary string that is used both in the headers and the generated request that you post. + # This builds a simple 30 hex digit string + @staticmethod + def generate_boundary_string() -> str: + random_digits = [random.SystemRandom().choice('0123456789abcdef') for n in range(30)] + s = "".join(random_digits) + return s \ No newline at end of file From 169f4235253f9bf7b7d92675031b71dbbf3212d0 Mon Sep 17 00:00:00 2001 From: Bryant Howell Date: Fri, 3 Jan 2020 14:19:10 -0600 Subject: [PATCH 07/14] Testing updates to fix some type checking in PublishedContent --- README.md | 6 +- ..._suite_all_querying_tableau_server_rest.py | 1 - tableau_documents/tableau_datasource.py | 55 ++++--------------- tableau_rest_api/methods/rest_api_base.py | 34 +++++++----- tableau_rest_api/permissions.py | 18 +++--- tableau_rest_api/published_content.py | 4 +- 6 files changed, 45 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index ac00c41..1ca0f52 100644 --- a/README.md +++ b/README.md @@ -171,10 +171,10 @@ The Logger class by default only logs Requests but not Responses. If you need to `Logger.enable_debug_level()`b -### 0.3 TableauBase class -Many classes within the tableau_tools package inherit from the TableauBase class. TableauBase implements the `enable_logging(Logger)` method, along with other a `.log()` method that calls to `Logger.log()`. It also has many static methods, mapping dicts, and helper classes related to Tableau in general. +### 0.3 TableauRestXml class +There is a class called TableauRestXml which holds static methods and properties that are useful on any Tableau REST XML request or response. -It should never be necessary to use TableauBase by itself. +TableauServerRest and TableauRestApiConnection both inherit from this class so you can call any of the methods from one of those objects rather than calling it directly. ### 0.4 tableau_exceptions The tableau_exceptions file defines a variety of Exceptions that are specific to Tableau, particularly the REST API. They are not very complex, and most simply include a msg property that will clarify the problem if logged diff --git a/examples/test_suite_all_querying_tableau_server_rest.py b/examples/test_suite_all_querying_tableau_server_rest.py index 409d21b..36ad4fe 100644 --- a/examples/test_suite_all_querying_tableau_server_rest.py +++ b/examples/test_suite_all_querying_tableau_server_rest.py @@ -32,7 +32,6 @@ def run_tests(server_url: str, username: str, password: str, site_content_url: s site_content_url=site_content_url) t.signin() t.enable_logging(rest_request_log_obj) - # Server info and methods server_info = t.query_server_info() server_version = t.query_server_version() diff --git a/tableau_documents/tableau_datasource.py b/tableau_documents/tableau_datasource.py index ea41684..bf71134 100644 --- a/tableau_documents/tableau_datasource.py +++ b/tableau_documents/tableau_datasource.py @@ -98,23 +98,11 @@ def __init__(self, datasource_xml: Optional[ET.Element] = None, logger_obj: Opti "Hyper": 'hyper', } - # Create from new or from existing object + # Create from new. Only doing 10.5 style for now. if datasource_xml is None: - if ds_version is None: - raise InvalidOptionException('When creating Datasource from scratch, must declare a ds_version') - self._ds_version = ds_version - version_split = self._ds_version.split('.') - if version_split[0] == '10': - if int(version_split[1]) < 5: - self.ds_version_type = '10' - else: - self.ds_version_type = '10.5' - elif version_split[0] == '9': - self.ds_version_type = '9' - else: - raise InvalidOptionException('Datasource being created with wrong version type') + self.ds_version_type = '10.5' self.xml = self.create_new_datasource_xml(ds_version) - + # Read existing data source else: self.xml = datasource_xml if self.xml.get("caption"): @@ -173,15 +161,6 @@ def __init__(self, datasource_xml: Optional[ET.Element] = None, logger_obj: Opti else: self.log('Found a Parameters datasource') - - #self.repository_location = None - - #if self.xml.find(u'repository-location') is not None: - # if len(self.xml.find(u'repository-location')) == 0: - # self._published = True - # repository_location_xml = self.xml.find(u'repository-location') - # self.repository_location = repository_location_xml - # Grab the extract filename if there is an extract section if self.xml.find('extract') is not None: e = self.xml.find('extract') @@ -282,16 +261,21 @@ def published_ds_content_url(self, new_content_url: str): # It seems some databases like Oracle and Teradata need this as well to swap a database def update_tables_with_new_database_or_schema(self, original_db_or_schema: str, new_db_or_schema: str): - for relation in self._tables_relations: + for relation in self.tables.table_relations: if relation.get('type') == "table": relation.set('table', relation.get('table').replace("[{}]".format(original_db_or_schema), "[{}]".format(new_db_or_schema))) + # Start of data sources creation methods (from scratch) + # Need considerable review and testing + @staticmethod def create_new_datasource_xml(version: str) -> ET.Element: # nsmap = {u"user": u'http://www.tableausoftware.com/xml/user'} + # The most basic component is just a datasource element with a version. ds_xml = ET.Element("datasource") ds_xml.set('version', version) + # Unclear if this is even necessary. May only appear in TWBXs ds_xml.set('inline', "true") return ds_xml @@ -300,9 +284,7 @@ def create_new_connection_xml(ds_version: str, ds_type: str, server: str, db_nam authentication: Optional[str] = None, initial_sql: Optional[str] = None) -> ET.Element: connection = ET.Element("connection") - if ds_version == '9': - c = connection - elif ds_version in ['10', '10.5']: + if ds_version in ['10', '10.5']: nc = ET.Element('named-connection') nc.set('caption', 'Connection') # Connection has a random number of 20 digits appended @@ -427,23 +409,6 @@ def translate_columns(self, translation_dict: Dict): self.columns.translate_captions(translation_dict=translation_dict) self.end_log_block() - def add_extract(self, new_extract_filename: str): - self.log('add_extract called, checking if extract exists already') - # Test to see if extract exists already - e = self.xml.find('extract') - if e is not None: - self.log("Existing extract found, no need to add") - raise AlreadyExistsException("An extract already exists, can't add a new one", "") - else: - self.log('Extract doesnt exist') - new_extract_filename_start = new_extract_filename.split(".")[0] - if self.ds_version_type == '10.5': - final_extract_filename = "{}.hyper".format(new_extract_filename_start) - else: - final_extract_filename = "{}.tde".format(new_extract_filename_start) - self._extract_filename = final_extract_filename - self.log('Adding extract to the data source') - def generate_extract_section(self) -> Union[ET.Element, bool]: # Short circuit if no extract had been set if self._extract_filename is None: diff --git a/tableau_rest_api/methods/rest_api_base.py b/tableau_rest_api/methods/rest_api_base.py index df6bcf6..72e3355 100644 --- a/tableau_rest_api/methods/rest_api_base.py +++ b/tableau_rest_api/methods/rest_api_base.py @@ -287,20 +287,24 @@ def signin(self, user_luid_to_impersonate: Optional[str] = None): self._request_obj.xml_request = tsr self._request_obj.http_verb = 'post' self.log('Login payload is\n {}'.format(ET.tostring(tsr))) + try: + self._request_obj.request_from_api(0) + # self.log(api.get_raw_response()) + xml = self._request_obj.get_response() - self._request_obj.request_from_api(0) - # self.log(api.get_raw_response()) - xml = self._request_obj.get_response() - - credentials_element = xml.findall('.//t:credentials', self.ns_map) - self.token = credentials_element[0].get("token") - self.log("Token is " + self.token) - self._request_obj.token = self.token - self.site_luid = credentials_element[0].findall(".//t:site", self.ns_map)[0].get("id") - self.user_luid = credentials_element[0].findall(".//t:user", self.ns_map)[0].get("id") - self.log("Site ID is " + self.site_luid) - self._request_obj.url = None - self._request_obj.xml_request = None + credentials_element = xml.findall('.//t:credentials', self.ns_map) + self.token = credentials_element[0].get("token") + self.log("Token is " + self.token) + self._request_obj.token = self.token + self.site_luid = credentials_element[0].findall(".//t:site", self.ns_map)[0].get("id") + self.user_luid = credentials_element[0].findall(".//t:user", self.ns_map)[0].get("id") + self.log("Site ID is " + self.site_luid) + self._request_obj.url = None + self._request_obj.xml_request = None + except RecoverableHTTPException as e: + if e.tableau_error_code == '401001': + self.end_log_block() + raise NotSignedInException('Sign-in failed due to wrong credentials') self.end_log_block() def swap_token(self, site_luid: str, user_luid: str, token: str): @@ -374,6 +378,8 @@ def query_resource(self, url_ending: str, server_level:bool = False, filters: Op sorts: Optional[List[Sort]] = None, additional_url_ending: Optional[str] = None, fields: Optional[List[str]] = None) -> ET.Element: self.start_log_block() + if self.token == "": + raise NotSignedInException('Must use .signin() to create REST API session first') url_endings = [] if filters is not None: if len(filters) > 0: @@ -525,6 +531,8 @@ def query_resource_json(self, url_ending: str, server_level: bool = False, sorts: Optional[List[Sort]] = None, additional_url_ending: str = None, fields: Optional[List[str]] = None, page_number: Optional[int] = None) -> Dict: self.start_log_block() + if self.token == "": + raise NotSignedInException('Must use .signin() to create REST API session first') url_endings = [] if filters is not None: if len(filters) > 0: diff --git a/tableau_rest_api/permissions.py b/tableau_rest_api/permissions.py index 0730980..68f8a3a 100644 --- a/tableau_rest_api/permissions.py +++ b/tableau_rest_api/permissions.py @@ -304,7 +304,7 @@ def __init__(self, group_or_user: str, luid: str, content_type: Optional[str] = '3.6': server_content_roles_3_5 } - self.__server_to_rest_capability_map = { + self.server_to_rest_capability_map = { 'Add Comment': 'AddComment', 'Move': 'ChangeHierarchy', 'Set Permissions': 'ChangePermissions', @@ -365,15 +365,15 @@ def group_or_user(self, group_or_user): # Just use the direct "to_allow" and "to_deny" methods def set_capability(self, capability_name: str, mode: str): - if capability_name not in list(self.__server_to_rest_capability_map.values()): + if capability_name not in list(self.server_to_rest_capability_map.values()): # If it's the Tableau UI naming, translate it over - if capability_name in self.__server_to_rest_capability_map: + if capability_name in self.server_to_rest_capability_map: # InheritedProjectLeader (2.8+) is Read-Only if capability_name == 'InheritedProjectLeader': self.log('InheritedProjectLeader permission is read-only, skipping') return if capability_name != 'all': - capability_name = self.__server_to_rest_capability_map[capability_name] + capability_name = self.server_to_rest_capability_map[capability_name] else: raise InvalidOptionException('"{}" is not a capability in REST API or Server'.format(capability_name)) self.capabilities[capability_name] = mode @@ -387,23 +387,23 @@ def set_capability_to_deny(self, capability_name: str): def set_capability_to_unspecified(self, capability_name: str): if capability_name not in self.capabilities: # If it's the Tableau UI naming, translate it over - if capability_name in self.__server_to_rest_capability_map: + if capability_name in self.server_to_rest_capability_map: if capability_name == 'InheritedProjectLeader': self.log('InheritedProjectLeader permission is read-only, skipping') return if capability_name != 'all': - capability_name = self.__server_to_rest_capability_map[capability_name] + capability_name = self.server_to_rest_capability_map[capability_name] else: raise InvalidOptionException('"{}" is not a capability in REST API or Server'.format(capability_name)) self.capabilities[capability_name] = None # This exists specifically to allow the setting of read-only permissions def _set_capability_from_published_content(self, capability_name: str, mode: str): - if capability_name not in list(self.__server_to_rest_capability_map.values()): + if capability_name not in list(self.server_to_rest_capability_map.values()): # If it's the Tableau UI naming, translate it over - if capability_name in self.__server_to_rest_capability_map: + if capability_name in self.server_to_rest_capability_map: if capability_name != 'all': - capability_name = self.__server_to_rest_capability_map[capability_name] + capability_name = self.server_to_rest_capability_map[capability_name] else: raise InvalidOptionException('"{}" is not a capability in REST API or Server'.format(capability_name)) self.capabilities[capability_name] = mode diff --git a/tableau_rest_api/published_content.py b/tableau_rest_api/published_content.py index 11d6340..a7ab699 100644 --- a/tableau_rest_api/published_content.py +++ b/tableau_rest_api/published_content.py @@ -950,9 +950,9 @@ def are_permissions_locked(self) -> bool: def lock_permissions(self): self.start_log_block() if self.permissions_locked is False: - if(isinstance(self.t_rest_api, TableauRestApiConnection)): + if(type(self.t_rest_api).__name__.contains('TableauRestApiConnection')): self.t_rest_api.update_project(self.luid, locked_permissions=True) - if(isinstance(self.t_rest_api, TableauServerRest)): + if(type(self.t_rest_api).__name__.contains('TableauServerRest')): self.t_rest_api.projects.update_project(self.luid, locked_permissions=True) self.end_log_block() From 82917e4af717f53b8806ea1cdc7f0bde2ab0d0f5 Mon Sep 17 00:00:00 2001 From: Bryant Howell Date: Fri, 3 Jan 2020 14:51:35 -0600 Subject: [PATCH 08/14] Bugfixes on logger and replaced the isinstance() type checking with type( ).__name__ so no need to import classes to check --- logger.py | 8 ++++---- tableau_rest_api/published_content.py | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/logger.py b/logger.py index 503898b..d291acd 100644 --- a/logger.py +++ b/logger.py @@ -53,7 +53,7 @@ def start_log_block(self): short_class = short_class[:-2] cur_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()).encode('utf-8') - log_line = '{}vv-- {} : {} {} started --------vv\n'.format(" "*self.log_depth, cur_time, short_class, caller_function_name) + log_line = '{}vv-- {} : {} {} started --------vv\n'.format(" "*self.log_depth, str(cur_time), short_class, caller_function_name) # Only move the log depth in debug mode if self._log_modes['debug'] is True: self.log_depth += 2 @@ -68,7 +68,7 @@ def end_log_block(self): cur_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()).encode('utf-8') if self._log_modes['debug'] is True: self.log_depth -= 2 - log_line = '{}^^-- {} : {} {} ended --------^^\n'.format(" "*self.log_depth, cur_time, short_class, caller_function_name) + log_line = '{}^^-- {} : {} {} ended --------^^\n'.format(" "*self.log_depth, str(cur_time), short_class, caller_function_name) self.__log_handle.write(log_line.encode('utf-8')) @@ -78,9 +78,9 @@ def log_uri(self, uri: str, verb: str): def log_xml_request(self, xml: Union[ET.Element, str], verb: str, uri: str): if self._log_modes['request'] is True: if isinstance(xml, str): - self.log('[{}}] \n{}'.format(verb.upper(), xml)) + self.log('[{}] \n{}'.format(verb.upper(), xml)) else: - self.log('[{}}] \n{}'.format(verb.upper(), ET.tostring(xml))) + self.log('[{}] \n{}'.format(verb.upper(), ET.tostring(xml))) else: self.log('[{}] {}'.format(verb.upper(), uri)) diff --git a/tableau_rest_api/published_content.py b/tableau_rest_api/published_content.py index a7ab699..9aa64a2 100644 --- a/tableau_rest_api/published_content.py +++ b/tableau_rest_api/published_content.py @@ -950,18 +950,18 @@ def are_permissions_locked(self) -> bool: def lock_permissions(self): self.start_log_block() if self.permissions_locked is False: - if(type(self.t_rest_api).__name__.contains('TableauRestApiConnection')): + if(type(self.t_rest_api).__name__.find('TableauRestApiConnection') != -1): self.t_rest_api.update_project(self.luid, locked_permissions=True) - if(type(self.t_rest_api).__name__.contains('TableauServerRest')): + if(type(self.t_rest_api).__name__.find('TableauServerRest') != -1): self.t_rest_api.projects.update_project(self.luid, locked_permissions=True) self.end_log_block() def unlock_permissions(self): self.start_log_block() if self.permissions_locked is True: - if(isinstance(self.t_rest_api, TableauRestApiConnection)): + if(type(self.t_rest_api).__name__.find('TableauRestApiConnection') != -1): self.t_rest_api.update_project(self.luid, locked_permissions=False) - if(isinstance(self.t_rest_api, TableauServerRest)): + if(type(self.t_rest_api).__name__.find('TableauServerRest') != -1): self.t_rest_api.projects.update_project(self.luid, locked_permissions=False) self.end_log_block() @@ -1047,9 +1047,9 @@ def parent_project_luid(self) -> str: def query_child_projects(self) -> ET.Element: self.start_log_block() - if (isinstance(self.t_rest_api, TableauRestApiConnection)): + if (type(self.t_rest_api).__name__.find('TableauRestApiConnection') != -1): projects = self.t_rest_api.query_projects() - elif (isinstance(self.t_rest_api, TableauServerRest)): + elif(type(self.t_rest_api).__name__.find('TableauServerRest') != -1): projects = self.t_rest_api.projects.query_projects() else: raise InvalidOptionException('t_rest_api needs to be either TableauRestApiConnection or TableauServerRest descended') From c327dea03eba9b860985dc116063fbd9290b7ae0 Mon Sep 17 00:00:00 2001 From: Bryant Howell Date: Fri, 3 Jan 2020 15:02:58 -0600 Subject: [PATCH 09/14] Fixed the type checking --- tableau_rest_api/published_content.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tableau_rest_api/published_content.py b/tableau_rest_api/published_content.py index 9aa64a2..589f68b 100644 --- a/tableau_rest_api/published_content.py +++ b/tableau_rest_api/published_content.py @@ -950,18 +950,20 @@ def are_permissions_locked(self) -> bool: def lock_permissions(self): self.start_log_block() if self.permissions_locked is False: + # This allows type checking without importing the class if(type(self.t_rest_api).__name__.find('TableauRestApiConnection') != -1): self.t_rest_api.update_project(self.luid, locked_permissions=True) - if(type(self.t_rest_api).__name__.find('TableauServerRest') != -1): + else: self.t_rest_api.projects.update_project(self.luid, locked_permissions=True) self.end_log_block() def unlock_permissions(self): self.start_log_block() if self.permissions_locked is True: + # This allows type checking without importing the class if(type(self.t_rest_api).__name__.find('TableauRestApiConnection') != -1): self.t_rest_api.update_project(self.luid, locked_permissions=False) - if(type(self.t_rest_api).__name__.find('TableauServerRest') != -1): + else: self.t_rest_api.projects.update_project(self.luid, locked_permissions=False) self.end_log_block() @@ -1047,12 +1049,12 @@ def parent_project_luid(self) -> str: def query_child_projects(self) -> ET.Element: self.start_log_block() + # This allows type checking without importing the class if (type(self.t_rest_api).__name__.find('TableauRestApiConnection') != -1): projects = self.t_rest_api.query_projects() - elif(type(self.t_rest_api).__name__.find('TableauServerRest') != -1): - projects = self.t_rest_api.projects.query_projects() else: - raise InvalidOptionException('t_rest_api needs to be either TableauRestApiConnection or TableauServerRest descended') + projects = self.t_rest_api.projects.query_projects() + child_projects = projects.findall('.//t:project[@parentProjectId="{}"]'.format(self.luid), self.t_rest_api.ns_map) self.end_log_block() return child_projects From 4f44efc196c08a17a52e2a39bf8ef7953627aaac Mon Sep 17 00:00:00 2001 From: Bryant Howell Date: Mon, 6 Jan 2020 12:00:06 -0600 Subject: [PATCH 10/14] More type hinting / definitions. Hunting for an issue with update_project not working quite right --- tableau_rest_api/methods/project.py | 6 +++--- tableau_rest_api/published_content.py | 30 ++++++++++++++++----------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/tableau_rest_api/methods/project.py b/tableau_rest_api/methods/project.py index 8941630..f8e2ebc 100644 --- a/tableau_rest_api/methods/project.py +++ b/tableau_rest_api/methods/project.py @@ -108,7 +108,7 @@ def update_project(self, name_or_luid: str, new_project_name: Optional[str] = No response = self.send_update_request(url, tsr) self.end_log_block() - return self.get_published_project_object(project_luid, response) + return self.get_published_project_object(project_name_or_luid=project_luid, project_xml_obj=response) def delete_projects(self, project_name_or_luid_s: Union[List[str], str]): self.start_log_block() @@ -252,7 +252,7 @@ def update_project(self, name_or_luid: str, parent_project_name_or_luid: Optiona response = self.send_update_request(url, tsr) self.end_log_block() - return self.get_published_project_object(project_luid, response) + return self.get_published_project_object(project_name_or_luid=project_luid, project_xml_obj=response) def query_project(self, project_name_or_luid: str) -> Project28: @@ -379,7 +379,7 @@ def update_project(self, name_or_luid: str, parent_project_name_or_luid: Optiona response = self.send_update_request(url, tsr) self.end_log_block() - return self.get_published_project_object(project_luid, response) + return self.get_published_project_object(project_name_or_luid=project_luid, project_xml_obj=response) class ProjectMethods34(ProjectMethods33): def __init__(self, rest_api_base: TableauRestApiBase34): diff --git a/tableau_rest_api/published_content.py b/tableau_rest_api/published_content.py index 589f68b..f7e3681 100644 --- a/tableau_rest_api/published_content.py +++ b/tableau_rest_api/published_content.py @@ -659,8 +659,9 @@ def convert_capabilities_xml_into_obj_list(xml_obj: ET.Element) -> List['Datasou return obj_list class Datasource28(Datasource): - def __init__(self, luid, tableau_rest_api_obj, default=False, logger_obj=None, - content_xml_obj=None): + def __init__(self, luid: str, tableau_rest_api_obj: Union['TableauRestApiConnection', 'TableauServerRest'], + default: bool = False, logger_obj: Optional[Logger] = None, + content_xml_obj: Optional[ET.Element] = None): Datasource.__init__(self, luid=luid, tableau_rest_api_obj=tableau_rest_api_obj, default=default, logger_obj=logger_obj, content_xml_obj=content_xml_obj) self.__available_capabilities = Permissions.available_capabilities[self.api_version]["datasource"] @@ -672,8 +673,9 @@ def get_permissions_obj(self, group_name_or_luid: Optional[str] = None, username role=role) class View(PublishedContent): - def __init__(self, luid, tableau_rest_api_obj, default=False, logger_obj=None, - content_xml_obj=None): + def __init__(self, luid: str, tableau_rest_api_obj: Union['TableauRestApiConnection', 'TableauServerRest'], + default: bool = False, logger_obj: Optional[Logger] = None, + content_xml_obj: Optional[ET.Element] = None): PublishedContent.__init__(self, luid, "view", tableau_rest_api_obj, default=default, logger_obj=logger_obj, content_xml_obj=content_xml_obj) self.__available_capabilities = Permissions.available_capabilities[self.api_version]["workbook"] @@ -763,10 +765,12 @@ def get_permissions_obj(self, group_name_or_luid: Optional[str] = None, username class Project(PublishedContent): - def __init__(self, luid, tableau_rest_api_obj, logger_obj=None, - content_xml_obj=None): - PublishedContent.__init__(self, luid, "project", tableau_rest_api_obj, + def __init__(self, luid: str, tableau_rest_api_obj: Union['TableauRestApiConnection', 'TableauServerRest'], + logger_obj: Optional[Logger] = None, content_xml_obj: Optional[ET.Element] = None): + PublishedContent.__init__(self, luid=luid, obj_type="project", tableau_rest_api_obj=tableau_rest_api_obj, logger_obj=logger_obj, content_xml_obj=content_xml_obj) + self.log('Building Project object from this XML: ') + self.log_xml_response(content_xml_obj) # projects in 9.2 have child workbook and datasource permissions self._workbook_defaults = Workbook(self.luid, self.t_rest_api, default=True, logger_obj=logger_obj) @@ -783,7 +787,7 @@ def luid(self): return self._luid @luid.setter - def luid(self, name_or_luid): + def luid(self, name_or_luid: str): if TableauRestXml.is_luid(name_or_luid): luid = name_or_luid else: @@ -1032,8 +1036,9 @@ def convert_all_permissions_to_list(self, all_permissions: Dict): class Project28(Project): - def __init__(self, luid, tableau_rest_api_obj, logger_obj=None, - content_xml_obj=None, parent_project_luid=None): + def __init__(self, luid: str, tableau_rest_api_obj: Union['TableauRestApiConnection', 'TableauServerRest'], + logger_obj: Optional[Logger] = None, + content_xml_obj: Optional[ET.Element] = None, parent_project_luid: Optional[str] = None): Project.__init__(self, luid=luid, tableau_rest_api_obj=tableau_rest_api_obj, logger_obj=logger_obj, content_xml_obj=content_xml_obj) self._parent_project_luid = parent_project_luid @@ -1121,8 +1126,9 @@ def create_datasource_permissions_object_for_user(self, username_or_luid: str, class Project33(Project28): - def __init__(self, luid, tableau_rest_api_obj, logger_obj=None, - content_xml_obj=None, parent_project_luid=None): + def __init__(self, luid: str, tableau_rest_api_obj: Union['TableauRestApiConnection', 'TableauServerRest'], + logger_obj: Optional[Logger] = None, content_xml_obj: Optional[ET.Element] = None, + parent_project_luid:str = None): Project28.__init__(self, luid=luid, tableau_rest_api_obj=tableau_rest_api_obj, logger_obj=logger_obj, content_xml_obj=content_xml_obj, parent_project_luid=parent_project_luid) self.flow_defaults = Flow33(self.luid, self.t_rest_api, default=True, logger_obj=logger_obj) From d520289a15695aa1031169665d40b26958efca35 Mon Sep 17 00:00:00 2001 From: Bryant Howell Date: Mon, 6 Jan 2020 14:45:42 -0600 Subject: [PATCH 11/14] lock and unlock_permissions() now return back an object - if no action, the same object, but if there is an update you actually end up with a new object that should replace what you were using previously --- tableau_rest_api/methods/project.py | 9 ++- tableau_rest_api/published_content.py | 100 +++++++++++++++++++++----- 2 files changed, 89 insertions(+), 20 deletions(-) diff --git a/tableau_rest_api/methods/project.py b/tableau_rest_api/methods/project.py index f8e2ebc..79aeabe 100644 --- a/tableau_rest_api/methods/project.py +++ b/tableau_rest_api/methods/project.py @@ -107,8 +107,9 @@ def update_project(self, name_or_luid: str, new_project_name: Optional[str] = No url += '?publishSamples=true' response = self.send_update_request(url, tsr) + proj_xml_obj = response.findall(".//t:project", TableauRestXml.ns_map)[0] self.end_log_block() - return self.get_published_project_object(project_name_or_luid=project_luid, project_xml_obj=response) + return self.get_published_project_object(project_name_or_luid=project_luid, project_xml_obj=proj_xml_obj) def delete_projects(self, project_name_or_luid_s: Union[List[str], str]): self.start_log_block() @@ -251,8 +252,9 @@ def update_project(self, name_or_luid: str, parent_project_name_or_luid: Optiona url += '?publishSamples=true' response = self.send_update_request(url, tsr) + proj_xml_obj = response.findall(".//t:project", TableauRestXml.ns_map)[0] self.end_log_block() - return self.get_published_project_object(project_name_or_luid=project_luid, project_xml_obj=response) + return self.get_published_project_object(project_name_or_luid=project_luid, project_xml_obj=proj_xml_obj) def query_project(self, project_name_or_luid: str) -> Project28: @@ -378,8 +380,9 @@ def update_project(self, name_or_luid: str, parent_project_name_or_luid: Optiona url += '?publishSamples=true' response = self.send_update_request(url, tsr) + proj_xml_obj = response.findall(".//t:project", TableauRestXml.ns_map)[0] self.end_log_block() - return self.get_published_project_object(project_name_or_luid=project_luid, project_xml_obj=response) + return self.get_published_project_object(project_name_or_luid=project_luid, project_xml_obj=proj_xml_obj) class ProjectMethods34(ProjectMethods33): def __init__(self, rest_api_base: TableauRestApiBase34): diff --git a/tableau_rest_api/published_content.py b/tableau_rest_api/published_content.py index f7e3681..5e9b199 100644 --- a/tableau_rest_api/published_content.py +++ b/tableau_rest_api/published_content.py @@ -28,10 +28,11 @@ def __init__(self, luid: str, obj_type: str, tableau_rest_api_obj: Union['Tablea self.current_perms_obj_list: Optional[List[Permissions]] = None self.__permissionable_objects = self.permissionable_objects self.get_permissions_from_server() - self.xml_obj = content_xml_obj + #self.log('Creating a Published Project Object from this XML:') + #self.log_xml_response(content_xml_obj) self.api_version = tableau_rest_api_obj.api_version self.permissions_object_class = ProjectPermissions # Override in any child class with specific - + self.xml_obj = content_xml_obj # If you want to know the name that matches to the group or user, need these # But no need to request every single time # self.groups_dict_cache = None @@ -660,7 +661,7 @@ def convert_capabilities_xml_into_obj_list(xml_obj: ET.Element) -> List['Datasou class Datasource28(Datasource): def __init__(self, luid: str, tableau_rest_api_obj: Union['TableauRestApiConnection', 'TableauServerRest'], - default: bool = False, logger_obj: Optional[Logger] = None, + default: bool = False, logger_obj: Optional['Logger'] = None, content_xml_obj: Optional[ET.Element] = None): Datasource.__init__(self, luid=luid, tableau_rest_api_obj=tableau_rest_api_obj, default=default, logger_obj=logger_obj, content_xml_obj=content_xml_obj) @@ -674,7 +675,7 @@ def get_permissions_obj(self, group_name_or_luid: Optional[str] = None, username class View(PublishedContent): def __init__(self, luid: str, tableau_rest_api_obj: Union['TableauRestApiConnection', 'TableauServerRest'], - default: bool = False, logger_obj: Optional[Logger] = None, + default: bool = False, logger_obj: Optional['Logger'] = None, content_xml_obj: Optional[ET.Element] = None): PublishedContent.__init__(self, luid, "view", tableau_rest_api_obj, default=default, logger_obj=logger_obj, content_xml_obj=content_xml_obj) @@ -766,11 +767,13 @@ def get_permissions_obj(self, group_name_or_luid: Optional[str] = None, username class Project(PublishedContent): def __init__(self, luid: str, tableau_rest_api_obj: Union['TableauRestApiConnection', 'TableauServerRest'], - logger_obj: Optional[Logger] = None, content_xml_obj: Optional[ET.Element] = None): + logger_obj: Optional['Logger'] = None, content_xml_obj: Optional[ET.Element] = None): PublishedContent.__init__(self, luid=luid, obj_type="project", tableau_rest_api_obj=tableau_rest_api_obj, logger_obj=logger_obj, content_xml_obj=content_xml_obj) self.log('Building Project object from this XML: ') self.log_xml_response(content_xml_obj) + self.log('Project object has this XML: ') + self.log_xml_response(self.xml_obj) # projects in 9.2 have child workbook and datasource permissions self._workbook_defaults = Workbook(self.luid, self.t_rest_api, default=True, logger_obj=logger_obj) @@ -951,26 +954,33 @@ def are_permissions_locked(self) -> bool: mapping = {'ManagedByOwner' : False, 'LockedToProject': True} return mapping[locked_permissions] - def lock_permissions(self): + def lock_permissions(self) -> 'Project': self.start_log_block() if self.permissions_locked is False: # This allows type checking without importing the class if(type(self.t_rest_api).__name__.find('TableauRestApiConnection') != -1): - self.t_rest_api.update_project(self.luid, locked_permissions=True) + new_proj_obj = self.t_rest_api.update_project(self.luid, locked_permissions=True) else: - self.t_rest_api.projects.update_project(self.luid, locked_permissions=True) - self.end_log_block() + new_proj_obj = self.t_rest_api.projects.update_project(self.luid, locked_permissions=True) + self.end_log_block() + return new_proj_obj + else: + self.end_log_block() + return self - def unlock_permissions(self): + def unlock_permissions(self) -> 'Project': self.start_log_block() if self.permissions_locked is True: # This allows type checking without importing the class if(type(self.t_rest_api).__name__.find('TableauRestApiConnection') != -1): - self.t_rest_api.update_project(self.luid, locked_permissions=False) + new_proj_obj = self.t_rest_api.update_project(self.luid, locked_permissions=False) else: - self.t_rest_api.projects.update_project(self.luid, locked_permissions=False) - - self.end_log_block() + new_proj_obj = self.t_rest_api.projects.update_project(self.luid, locked_permissions=False) + self.end_log_block() + return new_proj_obj + else: + self.end_log_block() + return self # These are speciality methods just for exporting everything out for audit def query_all_permissions(self) -> Dict: @@ -1037,10 +1047,10 @@ def convert_all_permissions_to_list(self, all_permissions: Dict): class Project28(Project): def __init__(self, luid: str, tableau_rest_api_obj: Union['TableauRestApiConnection', 'TableauServerRest'], - logger_obj: Optional[Logger] = None, + logger_obj: Optional['Logger'] = None, content_xml_obj: Optional[ET.Element] = None, parent_project_luid: Optional[str] = None): Project.__init__(self, luid=luid, tableau_rest_api_obj=tableau_rest_api_obj, logger_obj=logger_obj, - content_xml_obj=content_xml_obj) + content_xml_obj=content_xml_obj) self._parent_project_luid = parent_project_luid self.permissions_object_class = ProjectPermissions28 @@ -1064,6 +1074,34 @@ def query_child_projects(self) -> ET.Element: self.end_log_block() return child_projects + def lock_permissions(self) -> 'Project28': + self.start_log_block() + if self.permissions_locked is False: + # This allows type checking without importing the class + if(type(self.t_rest_api).__name__.find('TableauRestApiConnection') != -1): + new_proj_obj = self.t_rest_api.update_project(self.luid, locked_permissions=True) + else: + new_proj_obj = self.t_rest_api.projects.update_project(self.luid, locked_permissions=True) + self.end_log_block() + return new_proj_obj + else: + self.end_log_block() + return self + + def unlock_permissions(self) -> 'Project28': + self.start_log_block() + if self.permissions_locked is True: + # This allows type checking without importing the class + if(type(self.t_rest_api).__name__.find('TableauRestApiConnection') != -1): + new_proj_obj = self.t_rest_api.update_project(self.luid, locked_permissions=False) + else: + new_proj_obj = self.t_rest_api.projects.update_project(self.luid, locked_permissions=False) + self.end_log_block() + return new_proj_obj + else: + self.end_log_block() + return self + @staticmethod def convert_capabilities_xml_into_obj_list(xml_obj: ET.Element) -> List['ProjectPermissions']: # self.start_log_block() @@ -1127,12 +1165,40 @@ def create_datasource_permissions_object_for_user(self, username_or_luid: str, class Project33(Project28): def __init__(self, luid: str, tableau_rest_api_obj: Union['TableauRestApiConnection', 'TableauServerRest'], - logger_obj: Optional[Logger] = None, content_xml_obj: Optional[ET.Element] = None, + logger_obj: Optional['Logger'] = None, content_xml_obj: Optional[ET.Element] = None, parent_project_luid:str = None): Project28.__init__(self, luid=luid, tableau_rest_api_obj=tableau_rest_api_obj, logger_obj=logger_obj, content_xml_obj=content_xml_obj, parent_project_luid=parent_project_luid) self.flow_defaults = Flow33(self.luid, self.t_rest_api, default=True, logger_obj=logger_obj) + def lock_permissions(self) -> 'Project33': + self.start_log_block() + if self.permissions_locked is False: + # This allows type checking without importing the class + if(type(self.t_rest_api).__name__.find('TableauRestApiConnection') != -1): + new_proj_obj = self.t_rest_api.update_project(self.luid, locked_permissions=True) + else: + new_proj_obj = self.t_rest_api.projects.update_project(self.luid, locked_permissions=True) + self.end_log_block() + return new_proj_obj + else: + self.end_log_block() + return self + + def unlock_permissions(self) -> 'Project33': + self.start_log_block() + if self.permissions_locked is True: + # This allows type checking without importing the class + if(type(self.t_rest_api).__name__.find('TableauRestApiConnection') != -1): + new_proj_obj = self.t_rest_api.update_project(self.luid, locked_permissions=False) + else: + new_proj_obj = self.t_rest_api.projects.update_project(self.luid, locked_permissions=False) + self.end_log_block() + return new_proj_obj + else: + self.end_log_block() + return self + def get_permissions_obj(self, group_name_or_luid: Optional[str] = None, username_or_luid: Optional[str] = None, role: Optional[str] = None) -> 'ProjectPermissions28': return self._get_permissions_object(group_name_or_luid=group_name_or_luid, username_or_luid=username_or_luid, From 614679e021c6c2c4a41e5937cf8e5d92cf134d80 Mon Sep 17 00:00:00 2001 From: Bryant Howell Date: Mon, 6 Jan 2020 14:47:57 -0600 Subject: [PATCH 12/14] Updated README to reflect the returns from lock and unlock_permissions --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1ca0f52..57c95a7 100644 --- a/README.md +++ b/README.md @@ -912,12 +912,18 @@ For Projects, since the standard `query_project()` method returns the Project ob Projects have additional commands that the other classes do not: -`Project.lock_permissions()` +`Project.lock_permissions() -> Project` -`Project.unlock_permission()` +`Project.unlock_permission() -> Project` `Project.are_permissions_locked()` +If you are locking or unlocking permissions, you should replace the project object you used with the response that comes back: + + proj = t.projects.query_project('My Project') + proj = proj.lock_permissions() # You want to updated object returned here to use from here on out + ... + You access the default permissions objects with the following, which reference the objects of the correct type that have already been built within the Project object: `Project.workbook_defaults` From a74156e35c4a456ff12f60389a94f9a70ff0ceed Mon Sep 17 00:00:00 2001 From: Bryant Howell Date: Mon, 6 Jan 2020 14:57:52 -0600 Subject: [PATCH 13/14] Detailed the new Logger options --- README.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 57c95a7..410a696 100644 --- a/README.md +++ b/README.md @@ -166,10 +166,21 @@ If you want to log something in your script into this log, you can call where l is a string. You do not need to add a "\n", it will be added automatically. -The Logger class by default only logs Requests but not Responses. If you need to see the full responses, use the following method: +The Logger class, starting in tableau_tools 5.1, has multiple options to show different levels of response. -`Logger.enable_debug_level()`b +By default, the Logger will only show the HTTP requests with URI, along the chain of nested methods used to perform the actions. +`Logger.enable_request_logging()` + +will display the string version of the XML requests sent along with the HTTP requests. + +`Logger.enable_response_logging()` + +will display the string version of all XML responses in the logs. This is far more verbose, so is only suggested when you are encountering errors based on expectations of what should be in the response. + +`Logger.enable_debug_level()` + +makes the Logger indent the lines of the log, so that you can see the nesting of the actions that happen more easily. This is what the logs looked like in previous version of tableau_tools, but now it must be turned on if you want that mode. ### 0.3 TableauRestXml class There is a class called TableauRestXml which holds static methods and properties that are useful on any Tableau REST XML request or response. From 3dfde0ee5e048cbe6715e7ff3218c61dd3b003fa Mon Sep 17 00:00:00 2001 From: Bryant Howell Date: Mon, 6 Jan 2020 15:05:21 -0600 Subject: [PATCH 14/14] Fixed the indentation on the start and end log block methods. --- logger.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/logger.py b/logger.py index d291acd..06a791e 100644 --- a/logger.py +++ b/logger.py @@ -51,12 +51,17 @@ def start_log_block(self): class_path = c.split('.') short_class = class_path[len(class_path)-1] short_class = short_class[:-2] - cur_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()).encode('utf-8') + cur_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + - log_line = '{}vv-- {} : {} {} started --------vv\n'.format(" "*self.log_depth, str(cur_time), short_class, caller_function_name) # Only move the log depth in debug mode if self._log_modes['debug'] is True: self.log_depth += 2 + log_line = '{}vv-- {} : {} {} started --------vv\n'.format(" " * self.log_depth, str(cur_time), short_class, + caller_function_name) + else: + log_line = '{} : {} {} started --------vv\n'.format(str(cur_time), short_class, caller_function_name) + self.__log_handle.write(log_line.encode('utf-8')) def end_log_block(self): @@ -65,10 +70,12 @@ def end_log_block(self): class_path = c.split('.') short_class = class_path[len(class_path)-1] short_class = short_class[:-2] - cur_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()).encode('utf-8') + cur_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) if self._log_modes['debug'] is True: self.log_depth -= 2 - log_line = '{}^^-- {} : {} {} ended --------^^\n'.format(" "*self.log_depth, str(cur_time), short_class, caller_function_name) + log_line = '{}^^-- {} : {} {} ended --------^^\n'.format(" "*self.log_depth, str(cur_time), short_class, caller_function_name) + else: + log_line = '{} : {} {} ended --------^^\n'.format(str(cur_time), short_class, caller_function_name) self.__log_handle.write(log_line.encode('utf-8'))