8000 Add support to datasource and workbook revisions by elsherif · Pull Request #931 · tableau/server-client-python · GitHub
[go: up one dir, main page]

Skip to content

Add support to datasource and workbook revisions #931

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Nov 10, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion tableauserverclient/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
FlowItem,
WebhookItem,
PersonalAccessTokenAuth,
FlowRunItem
FlowRunItem,
RevisionItem
)
from .server import (
RequestOptions,
Expand Down
1 change: 1 addition & 0 deletions tableauserverclient/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 11 additions & 0 deletions tableauserverclient/models/datasource_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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)
Expand Down
62 changes: 62 additions & 0 deletions tableauserverclient/models/revision_item.py
Original file line number Diff line number Diff line change
@@ -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 (
"<RevisionItem# revisionNumber={_revision_number} "
"current={_current} deleted={_deleted}>".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"
11 changes: 11 additions & 0 deletions tableauserverclient/models/workbook_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion tableauserverclient/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
ColumnItem,
FlowItem,
WebhookItem,
FlowRunItem
FlowRunItem,
RevisionItem
)
from .endpoint import (
Auth,
Expand Down
68 changes: 68 additions & 0 deletions tableauserverclient/server/endpoint/datasources_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
68 changes: 68 additions & 0 deletions tableauserverclient/server/endpoint/workbooks_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
0