diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 2ad65d71e..cacb96a0d 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -34,7 +34,8 @@ FlowItem, WebhookItem, PersonalAccessTokenAuth, - FlowRunItem + FlowRunItem, + RevisionItem ) from .server import ( RequestOptions, diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index e5945782d..8b96b1b7f 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -36,3 +36,4 @@ from .permissions_item import PermissionsRule, Permission from .webhook_item import WebhookItem from .personal_access_token_auth import PersonalAccessTokenAuth +from .revision_item import RevisionItem diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 665be9db1..fb0dabe44 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -37,6 +37,7 @@ def __init__(self, project_id: str, name: str = None) -> None: self._id: Optional[str] = None self._initial_tags: Set = set() self._project_name: Optional[str] = None + self._revisions = None self._updated_at = None self._use_remote_query_agent = None self._webpage_url = None @@ -166,6 +167,13 @@ def use_remote_query_agent(self, value: bool): def webpage_url(self) -> Optional[str]: return self._webpage_url + @property + def revisions(self): + if self._revisions is None: + error = "Datasource item must be populated with revisions first." + raise UnpopulatedPropertyError(error) + return self._revisions() + def _set_connections(self, connections): self._connections = connections @@ -175,6 +183,9 @@ def _set_permissions(self, permissions): def _set_data_quality_warnings(self, dqws): self._data_quality_warnings = dqws + def _set_revisions(self, revisions): + self._revisions = revisions + def _parse_common_elements(self, datasource_xml, ns): if not isinstance(datasource_xml, ET.Element): datasource_xml = ET.fromstring(datasource_xml).find(".//t:datasource", namespaces=ns) diff --git a/tableauserverclient/models/revision_item.py b/tableauserverclient/models/revision_item.py new file mode 100644 index 000000000..f09d08e68 --- /dev/null +++ b/tableauserverclient/models/revision_item.py @@ -0,0 +1,62 @@ +import xml.etree.ElementTree as ET + +class RevisionItem(object): + def __init__(self): + self._resource_id = None + self._resource_name = None + self._revision_number = None + self._current = None + self._deleted = None + self._created_at = None + + @property + def resource_id(self): + return self._resource_id + + @property + def resource_name(self): + return self._resource_name + + @property + def revision_number(self): + return self._revision_number + + @property + def current(self): + return self._current + + @property + def deleted(self): + return self._deleted + + @property + def created_at(self): + return self._created_at + + def __repr__(self): + return ( + "".format(**self.__dict__) + ) + + @classmethod + def from_response(cls, resp, ns, resource_item): + all_revision_items = list() + parsed_response = ET.fromstring(resp) + all_revision_xml = parsed_response.findall(".//t:revision", namespaces=ns) + for revision_xml in all_revision_xml: + revision_item = cls() + revision_item._resource_id = resource_item.id + revision_item._resource_name = resource_item.name + revision_item._revision_number = revision_xml.get("revisionNumber", None) + revision_item._current = string_to_bool(revision_xml.get("current", "")) + revision_item._deleted = string_to_bool(revision_xml.get("deleted", "")) + revision_item._created_at = revision_xml.get("createdAt", None) + + all_revision_items.append(revision_item) + return all_revision_items + + +# Used to convert string represented boolean to a boolean type +def string_to_bool(s): + return s.lower() == "true" diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index ef0dc6f6f..77f21f7bb 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -38,6 +38,7 @@ def __init__(self, project_id: str, name: str = None, show_tabs: bool = False) - self._pdf = None self._preview_image = None self._project_name = None + self._revisions = None self._size = None self._updated_at = None self._views = None @@ -161,6 +162,13 @@ def data_acceleration_config(self): def data_acceleration_config(self, value): self._data_acceleration_config = value + @property + def revisions(self): + if self._revisions is None: + error = "Workbook item must be populated with revisions first." + raise UnpopulatedPropertyError(error) + return self._revisions() + def _set_connections(self, connections): self._connections = connections @@ -176,6 +184,9 @@ def _set_pdf(self, pdf): def _set_preview_image(self, preview_image): self._preview_image = preview_image + def _set_revisions(self, revisions): + self._revisions = revisions + def _parse_common_tags(self, workbook_xml, ns): if not isinstance(workbook_xml, ET.Element): workbook_xml = ET.fromstring(workbook_xml).find(".//t:workbook", namespaces=ns) diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index a03763969..5d6516cc0 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -32,7 +32,8 @@ ColumnItem, FlowItem, WebhookItem, - FlowRunItem + FlowRunItem, + RevisionItem ) from .endpoint import ( Auth, diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index f7a2a4405..98dd09bed 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -391,3 +391,71 @@ def add_dqw(self, item, warning): @api(version="3.5") def delete_dqw(self, item): self._data_quality_warnings.clear(item) + + # Populate datasource item's revisions + def populate_revisions(self, datasource_item): + if not datasource_item.id: + error = "Datasource item missing ID. Datasource must be retrieved from server first." + raise MissingRequiredFieldError(error) + + def revisions_fetcher(): + return self._get_datasource_revisions(datasource_item) + + datasource_item._set_revisions(revisions_fetcher) + logger.info( + "Populated revisions for datasource (ID: {0})".format(datasource_item.id) + ) + + def _get_datasource_revisions(self, datasource_item, req_options=None): + url = "{0}/{1}/revisions".format(self.baseurl, datasource_item.id) + server_response = self.get_request(url, req_options) + revisions = RevisionItem.from_response( + server_response.content, self.parent_srv.namespace, datasource_item + ) + return revisions + + # Download 1 datasource revision by revision number + def download_revision( + self, + datasource_id, + revision_number, + filepath=None, + include_extract=True, + no_extract=None, + ): + if not datasource_id: + error = "Datasource ID undefined." + raise ValueError(error) + url = "{0}/{1}/revisions/{2}/content".format( + self.baseurl, datasource_id, revision_number + ) + if no_extract is False or no_extract is True: + import warnings + + warnings.warn( + "no_extract is deprecated, use include_extract instead.", + DeprecationWarning, + ) + include_extract = not no_extract + + if not include_extract: + url += "?includeExtract=False" + + with closing( + self.get_request(url, parameters={"stream": True}) + ) as server_response: + _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) + filename = to_filename(os.path.basename(params["filename"])) + + download_path = make_download_path(filepath, filename) + + with open(download_path, "wb") as f: + for chunk in server_response.iter_content(1024): # 1KB + f.write(chunk) + + logger.info( + "Downloaded datasource revision {0} to {1} (ID: {2})".format( + revision_number, download_path, datasource_id + ) + ) + return os.path.abspath(download_path) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index a631ae170..328932335 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -430,3 +430,71 @@ def publish( new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] logger.info("Published {0} (ID: {1})".format(workbook_item.name, new_workbook.id)) return new_workbook + + # Populate workbook item's revisions + def populate_revisions(self, workbook_item): + if not workbook_item.id: + error = "Workbook item missing ID. Workbook must be retrieved from server first." + raise MissingRequiredFieldError(error) + + def revisions_fetcher(): + return self._get_workbook_revisions(workbook_item) + + workbook_item._set_revisions(revisions_fetcher) + logger.info( + "Populated revisions for workbook (ID: {0})".format(workbook_item.id) + ) + + def _get_workbook_revisions(self, workbook_item, req_options=None): + url = "{0}/{1}/revisions".format(self.baseurl, workbook_item.id) + server_response = self.get_request(url, req_options) + revisions = RevisionItem.from_response( + server_response.content, self.parent_srv.namespace, workbook_item + ) + return revisions + + # Download 1 workbook revision by revision number + def download_revision( + self, + workbook_id, + revision_number, + filepath=None, + include_extract=True, + no_extract=None, + ): + if not workbook_id: + error = "Workbook ID undefined." + raise ValueError(error) + url = "{0}/{1}/revisions/{2}/content".format( + self.baseurl, workbook_id, revision_number + ) + + if no_extract is False or no_extract is True: + import warnings + + warnings.warn( + "no_extract is deprecated, use include_extract instead.", + DeprecationWarning, + ) + include_extract = not no_extract + + if not include_extract: + url += "?includeExtract=False" + + with closing( + self.get_request(url, parameters={"stream": True}) + ) as server_response: + _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) + filename = to_filename(os.path.basename(params["filename"])) + + download_path = make_download_path(filepath, filename) + + with open(download_path, "wb") as f: + for chunk in server_response.iter_content(1024): # 1KB + f.write(chunk) + logger.info( + "Downloaded workbook revision {0} to {1} (ID: {2})".format( + revision_number, download_path, workbook_id + ) + ) + return os.path.abspath(download_path)