Skip to content

Commit

Permalink
Push code for 0.25 with custom views (#1206)
Browse files Browse the repository at this point in the history
* Implement custom view objects (#1195)
* Fix bug in update-datasources before 3.15 (#1203) (fixes #1072)
* catch exceptions from ServerInfo (#1204)
* add query-tagging attribute to connection (#1202) (add explanation for why it doesn't work on hyper)

---------

Co-authored-by: Marwan Baghdad <[email protected]>
Co-authored-by: jorwoods <[email protected]>
Co-authored-by: Brian Cantoni <[email protected]>
Co-authored-by: TrimPeachu <[email protected]>
Co-authored-by: Stu Tomlinson <[email protected]>
Co-authored-by: Jeremy Harris <[email protected]>
  • Loading branch information
7 people authored Mar 16, 2023
1 parent ccdd790 commit e4fbe41
Show file tree
Hide file tree
Showing 75 changed files with 963 additions and 426 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/code-coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
os: [ubuntu-latest]
python-version: ['3.10']

runs-on: ${{ matrix.os }}
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ classifiers = [
repository = "https://github.com/tableau/server-client-python"

[project.optional-dependencies]
test = ["argparse", "black", "mock", "mypy", "pytest>=7.0", "requests-mock>=1.0,<2.0"]
test = ["argparse", "black", "mock", "mypy", "pytest>=7.0", "pytest-subtests", "requests-mock>=1.0,<2.0"]

[tool.black]
line-length = 120
Expand Down
29 changes: 29 additions & 0 deletions samples/explore_workbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ def main():
if all_workbooks:
# Pick one workbook from the list
sample_workbook = all_workbooks[0]
sample_workbook.name = "Name me something cooler"
sample_workbook.description = "That doesn't work"
updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook)
print(updated.name, updated.description)

# Populate views
server.workbooks.populate_views(sample_workbook)
Expand Down Expand Up @@ -125,6 +129,31 @@ def main():
f.write(sample_workbook.preview_image)
print("\nDownloaded preview image of workbook to {}".format(os.path.abspath(args.preview_image)))

# get custom views
cvs, _ = server.custom_views.get()
for c in cvs:
print(c)

# for the last custom view in the list

# update the name
# note that this will fail if the name is already changed to this value
changed: TSC.CustomViewItem(id=c.id, name="I was updated by tsc")
verified_change = server.custom_views.update(changed)
print(verified_change)

# export as image. Filters etc could be added here as usual
server.custom_views.populate_image(c)
filename = c.id + "-image-export.png"
with open(filename, "wb") as f:
f.write(c.image)
print("saved to " + filename)

if args.delete:
print("deleting {}".format(c.id))
unlucky = TSC.CustomViewItem(c.id)
server.custom_views.delete(unlucky.id)


if __name__ == "__main__":
main()
39 changes: 1 addition & 38 deletions tableauserverclient/__init__.py
Original file line number Diff line number Diff line change
@@ -1,43 +1,6 @@
from ._version import get_versions
from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE
from .models import (
BackgroundJobItem,
ColumnItem,
ConnectionCredentials,
ConnectionItem,
DQWItem,
DailyInterval,
DataAlertItem,
DatabaseItem,
DatasourceItem,
FlowItem,
FlowRunItem,
GroupItem,
HourlyInterval,
IntervalItem,
JobItem,
MetricItem,
MonthlyInterval,
PaginationItem,
Permission,
PermissionsRule,
PersonalAccessTokenAuth,
ProjectItem,
RevisionItem,
ScheduleItem,
SiteItem,
SubscriptionItem,
TableItem,
TableauAuth,
Target,
TaskItem,
UnpopulatedPropertyError,
UserItem,
ViewItem,
WebhookItem,
WeeklyInterval,
WorkbookItem,
)
from .models import *
from .server import (
CSVRequestOptions,
ExcelRequestOptions,
Expand Down
3 changes: 3 additions & 0 deletions tableauserverclient/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
from .column_item import ColumnItem
from .connection_credentials import ConnectionCredentials
from .connection_item import ConnectionItem
from .custom_view_item import CustomViewItem
from .data_acceleration_report_item import DataAccelerationReportItem
from .data_alert_item import DataAlertItem
from .database_item import DatabaseItem
from .datasource_item import DatasourceItem
from .dqw_item import DQWItem
from .exceptions import UnpopulatedPropertyError
from .favorites_item import FavoriteItem
from .fileupload_item import FileuploadItem
from .flow_item import FlowItem
from .flow_run_item import FlowRunItem
from .group_item import GroupItem
Expand All @@ -31,6 +33,7 @@
from .table_item import TableItem
from .tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth
from .tableau_types import Resource, TableauItem, plural_type
from .tag_item import TagItem
from .target import Target
from .task_item import TaskItem
from .user_item import UserItem
Expand Down
30 changes: 24 additions & 6 deletions tableauserverclient/models/connection_item.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from typing import TYPE_CHECKING, List, Optional
import logging
from typing import List, Optional

from defusedxml.ElementTree import fromstring

from .connection_credentials import ConnectionCredentials

if TYPE_CHECKING:
from tableauserverclient.models.connection_credentials import ConnectionCredentials
from .property_decorators import property_is_boolean


class ConnectionItem(object):
Expand All @@ -18,7 +18,8 @@ def __init__(self):
self.server_address: Optional[str] = None
self.server_port: Optional[str] = None
self.username: Optional[str] = None
self.connection_credentials: Optional["ConnectionCredentials"] = None
self.connection_credentials: Optional[ConnectionCredentials] = None
self._query_tagging: Optional[bool] = None

@property
def datasource_id(self) -> Optional[str]:
Expand All @@ -36,6 +37,22 @@ def id(self) -> Optional[str]:
def connection_type(self) -> Optional[str]:
return self._connection_type

@property
def query_tagging(self) -> Optional[bool]:
return self._query_tagging

@query_tagging.setter
@property_is_boolean
def query_tagging(self, value: Optional[bool]):
# if connection type = hyper, Snowflake, or Teradata, we can't change this value: it is always true
if self._connection_type in ["hyper", "snowflake", "teradata"]:
logger = logging.getLogger("tableauserverclient.models.connection_item")
logger.debug(
"Cannot update value: Query tagging is always enabled for {} connections".format(self._connection_type)
)
return
self._query_tagging = value

def __repr__(self):
return "<ConnectionItem#{_id} embed={embed_password} type={_connection_type} username={username}>".format(
**self.__dict__
Expand All @@ -54,6 +71,7 @@ def from_response(cls, resp, ns) -> List["ConnectionItem"]:
connection_item.server_address = connection_xml.get("serverAddress", None)
connection_item.server_port = connection_xml.get("serverPort", None)
connection_item.username = connection_xml.get("userName", None)
connection_item._query_tagging = string_to_bool(connection_xml.get("queryTaggingEnabled", None))
datasource_elem = connection_xml.find(".//t:datasource", namespaces=ns)
if datasource_elem is not None:
connection_item._datasource_id = datasource_elem.get("id", None)
Expand Down Expand Up @@ -94,4 +112,4 @@ def from_xml_element(cls, parsed_response, ns) -> List["ConnectionItem"]:

# Used to convert string represented boolean to a boolean type
def string_to_bool(s: str) -> bool:
return s.lower() == "true"
return s is not None and s.lower() == "true"
156 changes: 156 additions & 0 deletions tableauserverclient/models/custom_view_item.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
from datetime import datetime

from defusedxml import ElementTree
from defusedxml.ElementTree import fromstring, tostring
from typing import Callable, List, Optional

from .exceptions import UnpopulatedPropertyError
from .user_item import UserItem
from .view_item import ViewItem
from .workbook_item import WorkbookItem
from ..datetime_helpers import parse_datetime


class CustomViewItem(object):
def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None:
self._content_url: Optional[str] = None # ?
self._created_at: Optional["datetime"] = None
self._id: Optional[str] = id
self._image: Optional[Callable[[], bytes]] = None
self._name: Optional[str] = name
self._shared: Optional[bool] = False
self._updated_at: Optional["datetime"] = None

self._owner: Optional[UserItem] = None
self._view: Optional[ViewItem] = None
self._workbook: Optional[WorkbookItem] = None

def __repr__(self: "CustomViewItem"):
view_info = ""
if self._view:
view_info = " view='{}'".format(self._view.name or self._view.id or "unknown")
wb_info = ""
if self._workbook:
wb_info = " workbook='{}'".format(self._workbook.name or self._workbook.id or "unknown")
owner_info = ""
if self._owner:
owner_info = " owner='{}'".format(self._owner.name or self._owner.id or "unknown")
return "<CustomViewItem id={} name=`{}`{}{}{}>".format(self.id, self.name, view_info, wb_info, owner_info)

def _set_image(self, image):
self._image = image

@property
def content_url(self) -> Optional[str]:
return self._content_url

@property
def created_at(self) -> Optional["datetime"]:
return self._created_at

@property
def id(self) -> Optional[str]:
return self._id

@property
def image(self) -> bytes:
if self._image is None:
error = "View item must be populated with its png image first."
raise UnpopulatedPropertyError(error)
return self._image()

@property
def name(self) -> Optional[str]:
return self._name

@name.setter
def name(self, value: str):
self._name = value

@property
def shared(self) -> Optional[bool]:
return self._shared

@shared.setter
def shared(self, value: bool):
self._shared = value

@property
def updated_at(self) -> Optional["datetime"]:
return self._updated_at

@property
def owner(self) -> Optional[UserItem]:
return self._owner

@owner.setter
def owner(self, value: UserItem):
self._owner = value

@property
def workbook(self) -> Optional[WorkbookItem]:
return self._workbook

@property
def view(self) -> Optional[ViewItem]:
return self._view

@classmethod
def from_response(cls, resp, ns, workbook_id="") -> Optional["CustomViewItem"]:
item = cls.list_from_response(resp, ns, workbook_id)
if not item or len(item) == 0:
return None
else:
return item[0]

@classmethod
def list_from_response(cls, resp, ns, workbook_id="") -> List["CustomViewItem"]:
return cls.from_xml_element(fromstring(resp), ns, workbook_id)

"""
<customView
id="37d015c6-bc28-4c88-989c-72c0a171f7aa"
name="New name 2"
createdAt="2016-02-03T23:35:09Z"
updatedAt="2022-09-28T23:56:01Z"
shared="false">
<view id="8e33ff19-a7a4-4aa5-9dd8-a171e2b9c29f" name="circle"/>
<workbook id="2fbe87c9-a7d8-45bf-b2b3-877a26ec9af5" name="marks and viz types 2"/>
<owner id="cdfe8548-84c8-418e-9b33-2c0728b2398a" name="workgroupuser"/>
</customView>
"""

@classmethod
def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["CustomViewItem"]:
all_view_items = list()
all_view_xml = parsed_response.findall(".//t:customView", namespaces=ns)
for custom_view_xml in all_view_xml:
cv_item = cls()
view_elem: ElementTree = custom_view_xml.find(".//t:view", namespaces=ns)
workbook_elem: str = custom_view_xml.find(".//t:workbook", namespaces=ns)
owner_elem: str = custom_view_xml.find(".//t:owner", namespaces=ns)
cv_item._created_at = parse_datetime(custom_view_xml.get("createdAt", None))
cv_item._updated_at = parse_datetime(custom_view_xml.get("updatedAt", None))
cv_item._content_url = custom_view_xml.get("contentUrl", None)
cv_item._id = custom_view_xml.get("id", None)
cv_item._name = custom_view_xml.get("name", None)

if owner_elem is not None:
parsed_owners = UserItem.from_response_as_owner(tostring(custom_view_xml), ns)
if parsed_owners and len(parsed_owners) > 0:
cv_item._owner = parsed_owners[0]

if view_elem is not None:
parsed_views = ViewItem.from_response(tostring(custom_view_xml), ns)
if parsed_views and len(parsed_views) > 0:
cv_item._view = parsed_views[0]

if workbook_id:
cv_item._workbook = WorkbookItem(workbook_id)
elif workbook_elem is not None:
parsed_workbooks = WorkbookItem.from_response(tostring(custom_view_xml), ns)
if parsed_workbooks and len(parsed_workbooks) > 0:
cv_item._workbook = parsed_workbooks[0]

all_view_items.append(cv_item)
return all_view_items
20 changes: 6 additions & 14 deletions tableauserverclient/models/data_alert_item.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import List, Optional, TYPE_CHECKING
from datetime import datetime
from typing import List, Optional

from defusedxml.ElementTree import fromstring

Expand All @@ -8,15 +9,6 @@
property_is_boolean,
)

if TYPE_CHECKING:
from datetime import datetime


from typing import List, Optional, TYPE_CHECKING

if TYPE_CHECKING:
from datetime import datetime


class DataAlertItem(object):
class Frequency:
Expand All @@ -30,8 +22,8 @@ def __init__(self):
self._id: Optional[str] = None
self._subject: Optional[str] = None
self._creatorId: Optional[str] = None
self._createdAt: Optional["datetime"] = None
self._updatedAt: Optional["datetime"] = None
self._createdAt: Optional[datetime] = None
self._updatedAt: Optional[datetime] = None
self._frequency: Optional[str] = None
self._public: Optional[bool] = None
self._owner_id: Optional[str] = None
Expand Down Expand Up @@ -90,11 +82,11 @@ def recipients(self) -> List[str]:
return self._recipients or list()

@property
def createdAt(self) -> Optional["datetime"]:
def createdAt(self) -> Optional[datetime]:
return self._createdAt

@property
def updatedAt(self) -> Optional["datetime"]:
def updatedAt(self) -> Optional[datetime]:
return self._updatedAt

@property
Expand Down
Loading

0 comments on commit e4fbe41

Please sign in to comment.