diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index d393a06d5..6d74c5c38 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -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 }} diff --git a/pyproject.toml b/pyproject.toml index c9672462a..ee793ec41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index 355319971..f242ace70 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -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) @@ -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() diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 212540d84..03e484372 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -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, diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 58e5ed6d1..b4a52f753 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -1,6 +1,7 @@ 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 @@ -8,6 +9,7 @@ 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 @@ -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 diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index a170c5300..4ed06b831 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -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): @@ -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]: @@ -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 "".format( **self.__dict__ @@ -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) @@ -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" diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py new file mode 100644 index 000000000..e0b47c738 --- /dev/null +++ b/tableauserverclient/models/custom_view_item.py @@ -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 "".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) + + """ + + + + + + """ + + @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 diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index 3882d14eb..65be233e3 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -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 @@ -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: @@ -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 @@ -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 diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 4a7a74c4b..b5568a778 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -1,31 +1,21 @@ import copy +import datetime import xml.etree.ElementTree as ET -from typing import Dict, List, Optional, Set, Tuple, TYPE_CHECKING +from typing import Dict, List, Optional, Set, Tuple from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime +from .connection_item import ConnectionItem from .exceptions import UnpopulatedPropertyError +from .permissions_item import PermissionsRule from .property_decorators import ( property_not_nullable, property_is_boolean, property_is_enum, ) +from .revision_item import RevisionItem from .tag_item import TagItem -from ..datetime_helpers import parse_datetime - -if TYPE_CHECKING: - from .permissions_item import PermissionsRule - from .connection_item import ConnectionItem - from .revision_item import RevisionItem - import datetime - -from typing import Dict, List, Optional, Set, Tuple, TYPE_CHECKING, Union - -if TYPE_CHECKING: - from .permissions_item import PermissionsRule - from .connection_item import ConnectionItem - from .revision_item import RevisionItem - import datetime class DatasourceItem(object): @@ -34,6 +24,14 @@ class AskDataEnablement: Disabled = "Disabled" SiteDefault = "SiteDefault" + def __repr__(self): + return "".format( + self._id, + self.name, + self.description or "No Description", + self.project_id, + ) + def __init__(self, project_id: str, name: Optional[str] = None) -> None: self._ask_data_enablement = None self._certified = None @@ -64,23 +62,23 @@ def __init__(self, project_id: str, name: Optional[str] = None) -> None: return None @property - def ask_data_enablement(self) -> Optional["DatasourceItem.AskDataEnablement"]: + def ask_data_enablement(self) -> Optional[AskDataEnablement]: return self._ask_data_enablement @ask_data_enablement.setter @property_is_enum(AskDataEnablement) - def ask_data_enablement(self, value: Optional["DatasourceItem.AskDataEnablement"]): + def ask_data_enablement(self, value: Optional[AskDataEnablement]): self._ask_data_enablement = value @property - def connections(self) -> Optional[List["ConnectionItem"]]: + def connections(self) -> Optional[List[ConnectionItem]]: if self._connections is None: error = "Datasource item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self) -> Optional[List["PermissionsRule"]]: + def permissions(self) -> Optional[List[PermissionsRule]]: if self._permissions is None: error = "Project item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -91,7 +89,7 @@ def content_url(self) -> Optional[str]: return self._content_url @property - def created_at(self) -> Optional["datetime.datetime"]: + def created_at(self) -> Optional[datetime.datetime]: return self._created_at @property @@ -162,7 +160,7 @@ def description(self, value: str): self._description = value @property - def updated_at(self) -> Optional["datetime.datetime"]: + def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at @property @@ -179,7 +177,7 @@ def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def revisions(self) -> List["RevisionItem"]: + def revisions(self) -> List[RevisionItem]: if self._revisions is None: error = "Datasource item must be populated with revisions first." raise UnpopulatedPropertyError(error) diff --git a/tableauserverclient/models/dqw_item.py b/tableauserverclient/models/dqw_item.py index 2baecee09..ada041481 100644 --- a/tableauserverclient/models/dqw_item.py +++ b/tableauserverclient/models/dqw_item.py @@ -1,6 +1,6 @@ from defusedxml.ElementTree import fromstring -from ..datetime_helpers import parse_datetime +from tableauserverclient.datetime_helpers import parse_datetime class DQWItem(object): diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index 18f0ecae2..f48910602 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -1,43 +1,41 @@ import copy +import datetime import xml.etree.ElementTree as ET -from typing import List, Optional, TYPE_CHECKING, Set +from typing import List, Optional, Set from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime +from .connection_item import ConnectionItem +from .dqw_item import DQWItem from .exceptions import UnpopulatedPropertyError +from .permissions_item import Permission from .property_decorators import property_not_nullable from .tag_item import TagItem -from ..datetime_helpers import parse_datetime - -if TYPE_CHECKING: - import datetime - -from typing import List, Optional, TYPE_CHECKING, Set - -if TYPE_CHECKING: - import datetime - from .connection_item import ConnectionItem - from .permissions_item import Permission - from .dqw_item import DQWItem class FlowItem(object): + def __repr__(self): + return " None: self._webpage_url: Optional[str] = None - self._created_at: Optional["datetime.datetime"] = None + self._created_at: Optional[datetime.datetime] = None self._id: Optional[str] = None self._initial_tags: Set[str] = set() self._project_name: Optional[str] = None - self._updated_at: Optional["datetime.datetime"] = None + self._updated_at: Optional[datetime.datetime] = None self.name: Optional[str] = name self.owner_id: Optional[str] = None self.project_id: str = project_id self.tags: Set[str] = set() self.description: Optional[str] = None - self._connections = None - self._permissions = None - self._data_quality_warnings = None + self._connections: Optional[ConnectionItem] = None + self._permissions: Optional[Permission] = None + self._data_quality_warnings: Optional[DQWItem] = None @property def connections(self): @@ -58,7 +56,7 @@ def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def created_at(self) -> Optional["datetime.datetime"]: + def created_at(self) -> Optional[datetime.datetime]: return self._created_at @property @@ -94,7 +92,7 @@ def project_name(self) -> Optional[str]: return self._project_name @property - def updated_at(self) -> Optional["datetime.datetime"]: + def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at def _set_connections(self, connections): diff --git a/tableauserverclient/models/flow_run_item.py b/tableauserverclient/models/flow_run_item.py index ce859a65b..12281f4f8 100644 --- a/tableauserverclient/models/flow_run_item.py +++ b/tableauserverclient/models/flow_run_item.py @@ -1,17 +1,10 @@ import itertools -from typing import Dict, List, Optional, Type, TYPE_CHECKING +from datetime import datetime +from typing import Dict, List, Optional, Type from defusedxml.ElementTree import fromstring -from ..datetime_helpers import parse_datetime - -if TYPE_CHECKING: - from datetime import datetime - -from typing import Dict, List, Optional, Type, TYPE_CHECKING - -if TYPE_CHECKING: - from datetime import datetime +from tableauserverclient.datetime_helpers import parse_datetime class FlowRunItem(object): @@ -19,8 +12,8 @@ def __init__(self) -> None: self._id: str = "" self._flow_id: Optional[str] = None self._status: Optional[str] = None - self._started_at: Optional["datetime"] = None - self._completed_at: Optional["datetime"] = None + self._started_at: Optional[datetime] = None + self._completed_at: Optional[datetime] = None self._progress: Optional[str] = None self._background_job_id: Optional[str] = None @@ -37,11 +30,11 @@ def status(self) -> Optional[str]: return self._status @property - def started_at(self) -> Optional["datetime"]: + def started_at(self) -> Optional[datetime]: return self._started_at @property - def completed_at(self) -> Optional["datetime"]: + def completed_at(self) -> Optional[datetime]: return self._completed_at @property diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index a9cb2dcce..96c3ae675 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -8,7 +8,7 @@ from .user_item import UserItem if TYPE_CHECKING: - from ..server import Pager + from tableauserverclient.server import Pager class GroupItem(object): diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index a7490e705..5a2636246 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,12 +1,10 @@ -from typing import List, Optional, TYPE_CHECKING +import datetime +from typing import List, Optional from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime from .flow_run_item import FlowRunItem -from ..datetime_helpers import parse_datetime - -if TYPE_CHECKING: - import datetime class JobItem(object): @@ -25,16 +23,16 @@ def __init__( id_: str, job_type: str, progress: str, - created_at: "datetime.datetime", - started_at: Optional["datetime.datetime"] = None, - completed_at: Optional["datetime.datetime"] = None, + created_at: datetime.datetime, + started_at: Optional[datetime.datetime] = None, + completed_at: Optional[datetime.datetime] = None, finish_code: int = 0, notes: Optional[List[str]] = None, mode: Optional[str] = None, workbook_id: Optional[str] = None, datasource_id: Optional[str] = None, flow_run: Optional[FlowRunItem] = None, - updated_at: Optional["datetime.datetime"] = None, + updated_at: Optional[datetime.datetime] = None, ): self._id = id_ self._type = job_type @@ -63,15 +61,15 @@ def progress(self) -> str: return self._progress @property - def created_at(self) -> "datetime.datetime": + def created_at(self) -> datetime.datetime: return self._created_at @property - def started_at(self) -> Optional["datetime.datetime"]: + def started_at(self) -> Optional[datetime.datetime]: return self._started_at @property - def completed_at(self) -> Optional["datetime.datetime"]: + def completed_at(self) -> Optional[datetime.datetime]: return self._completed_at @property @@ -116,7 +114,7 @@ def flow_run(self, value): self._flow_run = value @property - def updated_at(self) -> Optional["datetime.datetime"]: + def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at def __repr__(self): @@ -185,14 +183,14 @@ class Status: def __init__( self, id_: str, - created_at: "datetime.datetime", + created_at: datetime.datetime, priority: int, job_type: str, status: str, title: Optional[str] = None, subtitle: Optional[str] = None, - started_at: Optional["datetime.datetime"] = None, - ended_at: Optional["datetime.datetime"] = None, + started_at: Optional[datetime.datetime] = None, + ended_at: Optional[datetime.datetime] = None, ): self._id = id_ self._type = job_type @@ -223,15 +221,15 @@ def type(self) -> str: return self._type @property - def created_at(self) -> "datetime.datetime": + def created_at(self) -> datetime.datetime: return self._created_at @property - def started_at(self) -> Optional["datetime.datetime"]: + def started_at(self) -> Optional[datetime.datetime]: return self._started_at @property - def ended_at(self) -> Optional["datetime.datetime"]: + def ended_at(self) -> Optional[datetime.datetime]: return self._ended_at @property diff --git a/tableauserverclient/models/metric_item.py b/tableauserverclient/models/metric_item.py index a54d1e30e..4adc73fa8 100644 --- a/tableauserverclient/models/metric_item.py +++ b/tableauserverclient/models/metric_item.py @@ -1,11 +1,10 @@ import xml.etree.ElementTree as ET -from ..datetime_helpers import parse_datetime +from datetime import datetime +from typing import List, Optional, Set + +from tableauserverclient.datetime_helpers import parse_datetime from .property_decorators import property_is_boolean, property_is_datetime from .tag_item import TagItem -from typing import List, Optional, TYPE_CHECKING, Set - -if TYPE_CHECKING: - from datetime import datetime class MetricItem(object): @@ -14,8 +13,8 @@ def __init__(self, name: Optional[str] = None): self._name: Optional[str] = name self._description: Optional[str] = None self._webpage_url: Optional[str] = None - self._created_at: Optional["datetime"] = None - self._updated_at: Optional["datetime"] = None + self._created_at: Optional[datetime] = None + self._updated_at: Optional[datetime] = None self._suspended: Optional[bool] = None self._project_id: Optional[str] = None self._project_name: Optional[str] = None @@ -53,7 +52,7 @@ def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def created_at(self) -> Optional["datetime"]: + def created_at(self) -> Optional[datetime]: return self._created_at @created_at.setter @@ -62,7 +61,7 @@ def created_at(self, value: "datetime") -> None: self._created_at = value @property - def updated_at(self) -> Optional["datetime"]: + def updated_at(self) -> Optional[datetime]: return self._updated_at @updated_at.setter diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 74b167e9d..3bdc63092 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -1,18 +1,16 @@ import logging import xml.etree.ElementTree as ET +from typing import Dict, List, Optional from defusedxml.ElementTree import fromstring + from .exceptions import UnknownGranteeTypeError, UnpopulatedPropertyError from .group_item import GroupItem +from .reference_item import ResourceReference from .user_item import UserItem logger = logging.getLogger("tableau.models.permissions_item") -from typing import Dict, List, Optional, TYPE_CHECKING - -if TYPE_CHECKING: - from .reference_item import ResourceReference - class Permission: class Mode: @@ -43,7 +41,7 @@ class Capability: class PermissionsRule(object): - def __init__(self, grantee: "ResourceReference", capabilities: Dict[str, str]) -> None: + def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> None: self.grantee = grantee self.capabilities = capabilities @@ -80,7 +78,7 @@ def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: return rules @staticmethod - def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict[str, str]]) -> "ResourceReference": + def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict[str, str]]) -> ResourceReference: """Use Xpath magic and some string splitting to get the right object type from the xml""" # Get the first element in the tree with an 'id' attribute diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index a8430bfd0..21358431c 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,13 +1,12 @@ import logging import xml.etree.ElementTree as ET +from typing import List, Optional from defusedxml.ElementTree import fromstring from .exceptions import UnpopulatedPropertyError from .property_decorators import property_is_enum, property_not_empty -from typing import List, Optional - class ProjectItem(object): class ContentPermissions: @@ -15,6 +14,11 @@ class ContentPermissions: ManagedByOwner: str = "ManagedByOwner" LockedToProjectWithoutNested: str = "LockedToProjectWithoutNested" + def __repr__(self): + return "".format( + self._id, self.name, self.parent_id or "None (Top level)", self.content_permissions or "Not Set" + ) + def __init__( self, name: str, diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index af8883290..7c801a4b5 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -2,7 +2,7 @@ import re from functools import wraps -from ..datetime_helpers import parse_datetime +from tableauserverclient.datetime_helpers import parse_datetime def property_is_enum(enum_type): diff --git a/tableauserverclient/models/revision_item.py b/tableauserverclient/models/revision_item.py index 600d73168..a0e6a1bd5 100644 --- a/tableauserverclient/models/revision_item.py +++ b/tableauserverclient/models/revision_item.py @@ -1,11 +1,9 @@ -from typing import List, Optional, TYPE_CHECKING +from datetime import datetime +from typing import List, Optional from defusedxml.ElementTree import fromstring -from ..datetime_helpers import parse_datetime - -if TYPE_CHECKING: - from datetime import datetime +from tableauserverclient.datetime_helpers import parse_datetime class RevisionItem(object): @@ -15,7 +13,7 @@ def __init__(self): self._revision_number: Optional[str] = None self._current: Optional[bool] = None self._deleted: Optional[bool] = None - self._created_at: Optional["datetime"] = None + self._created_at: Optional[datetime] = None self._user_id: Optional[str] = None self._user_name: Optional[str] = None @@ -40,7 +38,7 @@ def deleted(self) -> Optional[bool]: return self._deleted @property - def created_at(self) -> Optional["datetime"]: + def created_at(self) -> Optional[datetime]: return self._created_at @property diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index 828034d23..54e4badbe 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -4,6 +4,7 @@ from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime from .interval_item import ( IntervalItem, HourlyInterval, @@ -16,7 +17,6 @@ property_not_nullable, property_is_int, ) -from ..datetime_helpers import parse_datetime Interval = Union[HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval] diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index 350ae3a0d..5f9395880 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -1,3 +1,4 @@ +import logging import warnings import xml @@ -35,11 +36,18 @@ def rest_api_version(self): @classmethod def from_response(cls, resp, ns): + logger = logging.getLogger("TSC.ServerInfo") try: parsed_response = fromstring(resp) except xml.etree.ElementTree.ParseError as error: - warnings.warn("Unexpected response for ServerInfo: {}".format(resp)) + logger.info("Unexpected response for ServerInfo: {}".format(resp)) + logger.info(error) return cls("Unknown", "Unknown", "Unknown") + except Exception as error: + logger.info("Unexpected response for ServerInfo: {}".format(resp)) + logger.info(error) + return cls("Unknown", "Unknown", "Unknown") + product_version_tag = parsed_response.find(".//t:productVersion", namespaces=ns) rest_api_version_tag = parsed_response.find(".//t:restApiVersion", namespaces=ns) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index e6bc3af24..813e812af 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -2,6 +2,7 @@ import xml.etree.ElementTree as ET from defusedxml.ElementTree import fromstring + from .property_decorators import ( property_is_enum, property_is_boolean, diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 24ba1d682..db21e4aa2 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -53,6 +53,8 @@ def site(self, value): class PersonalAccessTokenAuth(Credentials): def __init__(self, token_name, personal_access_token, site_id=None): + if personal_access_token is None or token_name is None: + raise TabError("Must provide a token and token name when using PAT authentication") super().__init__(site_id=site_id) self.token_name = token_name self.personal_access_token = personal_access_token diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index 6ed77318f..9649c7ed9 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -1,13 +1,11 @@ -from tableauserverclient.models.database_item import DatabaseItem -from tableauserverclient.models.datasource_item import DatasourceItem -from tableauserverclient.models.flow_item import FlowItem -from tableauserverclient.models.project_item import ProjectItem -from tableauserverclient.models.table_item import TableItem -from tableauserverclient.models.view_item import ViewItem -from tableauserverclient.models.workbook_item import WorkbookItem - from typing import Union +from .datasource_item import DatasourceItem +from .flow_item import FlowItem +from .project_item import ProjectItem +from .view_item import ViewItem +from .workbook_item import WorkbookItem + class Resource: Database = "database" diff --git a/tableauserverclient/models/tag_item.py b/tableauserverclient/models/tag_item.py index f7568ae45..afa0a0762 100644 --- a/tableauserverclient/models/tag_item.py +++ b/tableauserverclient/models/tag_item.py @@ -1,5 +1,6 @@ -from typing import Set import xml.etree.ElementTree as ET +from typing import Set + from defusedxml.ElementTree import fromstring diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 32299a853..159869b07 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -1,8 +1,8 @@ from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime from .schedule_item import ScheduleItem from .target import Target -from ..datetime_helpers import parse_datetime class TaskItem(object): diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index c19fd4f97..5e3d18fa6 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -1,23 +1,21 @@ import io -import logging import xml.etree.ElementTree as ET from datetime import datetime from enum import IntEnum +from typing import Dict, List, Optional, TYPE_CHECKING, Tuple from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime from .exceptions import UnpopulatedPropertyError from .property_decorators import ( property_is_enum, property_not_empty, ) from .reference_item import ResourceReference -from ..datetime_helpers import parse_datetime - -from typing import Dict, List, Optional, TYPE_CHECKING, Tuple if TYPE_CHECKING: - from ..server.pager import Pager + from tableauserverclient.server import Pager class UserItem(object): @@ -93,6 +91,10 @@ def external_auth_user_id(self) -> Optional[str]: def id(self) -> Optional[str]: return self._id + @id.setter + def id(self, value: str) -> None: + self._id = value + @property def last_login(self) -> Optional[datetime]: return self._last_login @@ -102,7 +104,6 @@ def name(self) -> Optional[str]: return self._name @name.setter - @property_not_empty def name(self, value: str): self._name = value @@ -206,9 +207,19 @@ def _set_values( @classmethod def from_response(cls, resp, ns) -> List["UserItem"]: + element_name = ".//t:user" + return cls._parse_xml(element_name, resp, ns) + + @classmethod + def from_response_as_owner(cls, resp, ns) -> List["UserItem"]: + element_name = ".//t:owner" + return cls._parse_xml(element_name, resp, ns) + + @classmethod + def _parse_xml(cls, element_name, resp, ns): all_user_items = [] parsed_response = fromstring(resp) - all_user_xml = parsed_response.findall(".//t:user", namespaces=ns) + all_user_xml = parsed_response.findall(element_name, namespaces=ns) for user_xml in all_user_xml: ( id, diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index 01635349b..51cceaa9f 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,21 +1,19 @@ import copy -from typing import Callable, Generator, Iterator, List, Optional, Set, TYPE_CHECKING +from datetime import datetime +from typing import Callable, Iterator, List, Optional, Set from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime from .exceptions import UnpopulatedPropertyError +from .permissions_item import PermissionsRule from .tag_item import TagItem -from ..datetime_helpers import parse_datetime - -if TYPE_CHECKING: - from datetime import datetime - from .permissions_item import PermissionsRule class ViewItem(object): def __init__(self) -> None: self._content_url: Optional[str] = None - self._created_at: Optional["datetime"] = None + self._created_at: Optional[datetime] = None self._id: Optional[str] = None self._image: Optional[Callable[[], bytes]] = None self._initial_tags: Set[str] = set() @@ -28,11 +26,16 @@ def __init__(self) -> None: self._excel: Optional[Callable[[], Iterator[bytes]]] = None self._total_views: Optional[int] = None self._sheet_type: Optional[str] = None - self._updated_at: Optional["datetime"] = None + self._updated_at: Optional[datetime] = None self._workbook_id: Optional[str] = None - self._permissions: Optional[Callable[[], List["PermissionsRule"]]] = None + self._permissions: Optional[Callable[[], List[PermissionsRule]]] = None self.tags: Set[str] = set() + def __repr__(self): + return "".format( + self._id, self.name, self.content_url, self.project_id + ) + def _set_preview_image(self, preview_image): self._preview_image = preview_image @@ -53,7 +56,7 @@ def content_url(self) -> Optional[str]: return self._content_url @property - def created_at(self) -> Optional["datetime"]: + def created_at(self) -> Optional[datetime]: return self._created_at @property @@ -119,7 +122,7 @@ def total_views(self): return self._total_views @property - def updated_at(self) -> Optional["datetime"]: + def updated_at(self) -> Optional[datetime]: return self._updated_at @property @@ -127,13 +130,13 @@ def workbook_id(self) -> Optional[str]: return self._workbook_id @property - def permissions(self) -> List["PermissionsRule"]: + def permissions(self) -> List[PermissionsRule]: if self._permissions is None: error = "View item must be populated with permissions first." raise UnpopulatedPropertyError(error) return self._permissions() - def _set_permissions(self, permissions: Callable[[], List["PermissionsRule"]]) -> None: + def _set_permissions(self, permissions: Callable[[], List[PermissionsRule]]) -> None: self._permissions = permissions @classmethod diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 6d9a21b6b..debbf30b5 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -1,35 +1,22 @@ import copy +import datetime import uuid import xml.etree.ElementTree as ET -from typing import Callable, Dict, List, Optional, Set, TYPE_CHECKING +from typing import Callable, Dict, List, Optional, Set from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime +from .connection_item import ConnectionItem from .exceptions import UnpopulatedPropertyError from .permissions_item import PermissionsRule from .property_decorators import ( - property_not_nullable, property_is_boolean, property_is_data_acceleration_config, ) +from .revision_item import RevisionItem from .tag_item import TagItem from .view_item import ViewItem -from ..datetime_helpers import parse_datetime - - -if TYPE_CHECKING: - from .connection_item import ConnectionItem - from .permissions_item import PermissionsRule - import datetime - from .revision_item import RevisionItem - -from typing import Dict, List, Optional, Set, TYPE_CHECKING, Union - -if TYPE_CHECKING: - from .connection_item import ConnectionItem - from .permissions_item import PermissionsRule - import datetime - from .revision_item import RevisionItem class WorkbookItem(object): @@ -65,15 +52,20 @@ def __init__(self, project_id: str, name: Optional[str] = None, show_tabs: bool return None + def __repr__(self): + return "".format( + self._id, self.name, self.content_url, self.project_id + ) + @property - def connections(self) -> List["ConnectionItem"]: + def connections(self) -> List[ConnectionItem]: if self._connections is None: error = "Workbook item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self) -> List["PermissionsRule"]: + def permissions(self) -> List[PermissionsRule]: if self._permissions is None: error = "Workbook item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -88,7 +80,7 @@ def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def created_at(self) -> Optional["datetime.datetime"]: + def created_at(self) -> Optional[datetime.datetime]: return self._created_at @property @@ -146,7 +138,7 @@ def size(self): return self._size @property - def updated_at(self) -> Optional["datetime.datetime"]: + def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at @property @@ -176,7 +168,7 @@ def data_acceleration_config(self, value): self._data_acceleration_config = value @property - def revisions(self) -> List["RevisionItem"]: + def revisions(self) -> List[RevisionItem]: if self._revisions is None: error = "Workbook item must be populated with revisions first." raise UnpopulatedPropertyError(error) diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 84d118a2e..bcea2604e 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -10,56 +10,8 @@ from .filter import Filter from .sort import Sort -from ..models import ( - BackgroundJobItem, - ColumnItem, - ConnectionItem, - DQWItem, - DataAlertItem, - DatabaseItem, - DatasourceItem, - FlowItem, - FlowRunItem, - GroupItem, - JobItem, - PaginationItem, - Permission, - PermissionsRule, - ProjectItem, - RevisionItem, - ScheduleItem, - SiteItem, - SubscriptionItem, - TableItem, - TableauAuth, - TaskItem, - UserItem, - ViewItem, - WebhookItem, - WorkbookItem, - TableauItem, - Resource, - plural_type, -) -from .endpoint import ( - Auth, - DataAlerts, - Datasources, - Endpoint, - Groups, - Projects, - Schedules, - Sites, - Tables, - Users, - Views, - Workbooks, - Subscriptions, - ServerResponseError, - MissingRequiredFieldError, - Flows, - Favorites, -) +from ..models import * +from .endpoint import * from .server import Server from .pager import Pager from .exceptions import NotSignedInError diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index e14bb8cff..e8e1bc0f9 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -1,4 +1,5 @@ from .auth_endpoint import Auth +from .custom_views_endpoint import CustomViews from .data_acceleration_report_endpoint import DataAccelerationReport from .data_alert_endpoint import DataAlerts from .databases_endpoint import Databases diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py new file mode 100644 index 000000000..778cafecc --- /dev/null +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -0,0 +1,104 @@ +import logging +from typing import List, Optional, Tuple + +from .endpoint import QuerysetEndpoint, api +from .exceptions import MissingRequiredFieldError +from tableauserverclient.models import CustomViewItem, PaginationItem +from tableauserverclient.server import RequestFactory, RequestOptions, ImageRequestOptions + +logger = logging.getLogger("tableau.endpoint.custom_views") + +""" +Get a list of custom views on a site +get the details of a custom view +download an image of a custom view. +Delete a custom view +update the name or owner of a custom view. +""" + + +class CustomViews(QuerysetEndpoint): + def __init__(self, parent_srv): + super(CustomViews, self).__init__(parent_srv) + + @property + def baseurl(self) -> str: + return "{0}/sites/{1}/customviews".format(self.parent_srv.baseurl, self.parent_srv.site_id) + + """ + If the request has no filter parameters: Administrators will see all custom views. + Other users will see only custom views that they own. + If the filter parameters include ownerId: Users will see only custom views that they own. + If the filter parameters include viewId and/or workbookId, and don't include ownerId: + Users will see those custom views that they have Write and WebAuthoring permissions for. + If site user visibility is not set to Limited, the Users will see those custom views that are "public", + meaning the value of their shared attribute is true. + If site user visibility is set to Limited, ???? + """ + + @api(version="3.18") + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[CustomViewItem], PaginationItem]: + logger.info("Querying all custom views on site") + url = self.baseurl + server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + all_view_items = CustomViewItem.list_from_response(server_response.content, self.parent_srv.namespace) + return all_view_items, pagination_item + + @api(version="3.18") + def get_by_id(self, view_id: str) -> Optional[CustomViewItem]: + if not view_id: + error = "Custom view item missing ID." + raise MissingRequiredFieldError(error) + logger.info("Querying custom view (ID: {0})".format(view_id)) + url = "{0}/{1}".format(self.baseurl, view_id) + server_response = self.get_request(url) + return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) + + @api(version="3.18") + def populate_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"] = None) -> None: + if not view_item.id: + error = "Custom View item missing ID." + raise MissingRequiredFieldError(error) + + def image_fetcher(): + return self._get_view_image(view_item, req_options) + + view_item._set_image(image_fetcher) + logger.info("Populated image for custom view (ID: {0})".format(view_item.id)) + + def _get_view_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes: + url = "{0}/{1}/image".format(self.baseurl, view_item.id) + server_response = self.get_request(url, req_options) + image = server_response.content + return image + + """ + Not yet implemented: pdf or csv exports + """ + + @api(version="3.18") + def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: + if not view_item.id: + error = "Custom view item missing ID." + raise MissingRequiredFieldError(error) + if not (view_item.owner or view_item.name or view_item.shared): + logger.debug("No changes to make") + return view_item + + # Update the custom view owner or name + url = "{0}/{1}".format(self.baseurl, view_item.id) + update_req = RequestFactory.CustomView.update_req(view_item) + server_response = self.put_request(url, update_req) + logger.info("Updated custom view (ID: {0})".format(view_item.id)) + return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) + + # Delete 1 view by id + @api(version="3.19") + def delete(self, view_id: str) -> None: + if not view_id: + error = "Custom View ID undefined." + raise ValueError(error) + url = "{0}/{1}".format(self.baseurl, view_id) + self.delete_request(url) + logger.info("Deleted single custom view (ID: {0})".format(view_id)) diff --git a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py index f972c0d60..28e5495c5 100644 --- a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py +++ b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py @@ -3,7 +3,7 @@ from .default_permissions_endpoint import _DefaultPermissionsEndpoint from .endpoint import api, Endpoint from .permissions_endpoint import _PermissionsEndpoint -from ...models.data_acceleration_report_item import DataAccelerationReportItem +from tableauserverclient.models import DataAccelerationReportItem logger = logging.getLogger("tableau.endpoint.data_acceleration_report") diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py index 8929f8c6a..5af4e0464 100644 --- a/tableauserverclient/server/endpoint/data_alert_endpoint.py +++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py @@ -2,7 +2,8 @@ from .endpoint import api, Endpoint from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, DataAlertItem, PaginationItem, UserItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import DataAlertItem, PaginationItem, UserItem logger = logging.getLogger("tableau.endpoint.dataAlerts") diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index aa9d73f18..2522ef53e 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -5,7 +5,8 @@ from .endpoint import api, Endpoint from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .. import RequestFactory, DatabaseItem, TableItem, PaginationItem, Resource +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import DatabaseItem, TableItem, PaginationItem, Resource logger = logging.getLogger("tableau.endpoint.databases") diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 97c39d1bb..0c5b8ba61 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -1,40 +1,49 @@ import cgi import copy -import io import json import logging +import io import os + from contextlib import closing from pathlib import Path -from typing import ( - List, - Mapping, - Optional, - Sequence, - Tuple, - TYPE_CHECKING, - Union, -) +from typing import List, Mapping, Optional, Sequence, Tuple, TYPE_CHECKING, Union + +if TYPE_CHECKING: + from tableauserverclient.server import Server + from tableauserverclient.models import PermissionsRule + from .schedules_endpoint import AddResponse from .dqw_endpoint import _DataQualityWarningEndpoint from .endpoint import QuerysetEndpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint from .resource_tagger import _ResourceTagger -from .. import RequestFactory, DatasourceItem, PaginationItem, ConnectionItem, RequestOptions -from ..query import QuerySet -from ...filesys_helpers import ( + +from tableauserverclient.server import RequestFactory, RequestOptions +from tableauserverclient.filesys_helpers import ( to_filename, make_download_path, get_file_type, get_file_object_size, ) -from ...models import ConnectionCredentials, RevisionItem -from ...models.job_item import JobItem +from tableauserverclient.models import ( + ConnectionCredentials, + ConnectionItem, + DatasourceItem, + JobItem, + RevisionItem, + PaginationItem, +) +io_types = (io.BytesIO, io.BufferedReader) io_types_r = (io.BytesIO, io.BufferedReader) io_types_w = (io.BytesIO, io.BufferedWriter) +FilePath = Union[str, os.PathLike] +FileObject = Union[io.BufferedReader, io.BytesIO] +PathOrFile = Union[FilePath, FileObject] + # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -42,11 +51,6 @@ logger = logging.getLogger("tableau.endpoint.datasources") -if TYPE_CHECKING: - from ..server import Server - from ...models import PermissionsRule - from .schedules_endpoint import AddResponse - FilePath = Union[str, os.PathLike] FileObjectR = Union[io.BufferedReader, io.BytesIO] FileObjectW = Union[io.BufferedWriter, io.BytesIO] @@ -136,11 +140,20 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: if not datasource_item.id: error = "Datasource item missing ID. Datasource must be retrieved from server first." raise MissingRequiredFieldError(error) + # bug - before v3.15 you must always include the project id + if datasource_item.owner_id and not datasource_item.project_id: + if not self.parent_srv.check_at_least_version("3.15"): + error = ( + "Attempting to set new owner but datasource is missing Project ID." + "In versions before 3.15 the project id must be included to update the owner." + ) + raise MissingRequiredFieldError(error) self._resource_tagger.update_tags(self.baseurl, datasource_item) # Update the datasource itself url = "{0}/{1}".format(self.baseurl, datasource_item.id) + update_req = RequestFactory.Datasource.update_req(datasource_item) server_response = self.put_request(url, update_req) logger.info("Updated datasource item (ID: {0})".format(datasource_item.id)) diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 66fc23d49..b0d16efaf 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -2,8 +2,8 @@ from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError -from .. import RequestFactory -from ...models import DatabaseItem, PermissionsRule, ProjectItem, plural_type, Resource +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import DatabaseItem, PermissionsRule, ProjectItem, plural_type, Resource from typing import TYPE_CHECKING, Callable, List, Optional, Sequence, Union if TYPE_CHECKING: diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index ff1637721..96cb7c5f9 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -2,7 +2,8 @@ from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, DQWItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import DQWItem logger = logging.getLogger(__name__) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index b1a42b20c..9c933c9dd 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -3,7 +3,7 @@ from packaging.version import Version from functools import wraps from xml.etree.ElementTree import ParseError -from typing import Any, Callable, Dict, Optional, TYPE_CHECKING, Mapping +from typing import Any, Callable, Dict, Optional, TYPE_CHECKING from .exceptions import ( ServerResponseError, @@ -11,8 +11,8 @@ NonXMLResponseError, EndpointUnavailableError, ) -from ..query import QuerySet -from ... import helpers, get_versions +from tableauserverclient.server.query import QuerySet +from tableauserverclient import helpers, get_versions if TYPE_CHECKING: from ..server import Server @@ -78,16 +78,16 @@ def _make_request( self.parent_srv.http_options, auth_token, content, content_type, parameters ) - logger.debug("request {}, url: {}".format(method, url)) + logger.debug("request method {}, url: {}".format(method.__name__, url)) if content: redacted = helpers.strings.redact_xml(content[:1000]) - logger.debug("request content: {}".format(redacted)) + # logger.debug("request content: {}".format(redacted)) server_response = method(url, **parameters) self._check_status(server_response, url) loggable_response = self.log_response_safely(server_response) - logger.debug("Server response from {0}:\n\t{1}".format(url, loggable_response)) + # logger.debug("Server response from {0}:\n\t{1}".format(url, loggable_response)) if content_type == "application/xml": self.parent_srv._namespace.detect(server_response.content) @@ -258,7 +258,7 @@ def all(self, *args, **kwargs): return queryset @api(version="2.0") - def filter(self, *_, **kwargs): + def filter(self, *_, **kwargs) -> QuerySet: if _: raise RuntimeError("Only keyword arguments accepted.") queryset = QuerySet(self).filter(**kwargs) diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 19199c5a0..5105b3bf4 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,10 +1,8 @@ import logging from .endpoint import Endpoint, api -from .. import RequestFactory -from ...models import FavoriteItem - -logger = logging.getLogger("tableau.endpoint.favorites") +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import FavoriteItem from typing import Optional, TYPE_CHECKING @@ -12,6 +10,8 @@ from ...models import DatasourceItem, FlowItem, ProjectItem, UserItem, ViewItem, WorkbookItem from ..request_options import RequestOptions +logger = logging.getLogger("tableau.endpoint.favorites") + class Favorites(Endpoint): @property diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 3df8ee4d5..9a8e9560d 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -1,8 +1,8 @@ import logging from .endpoint import Endpoint, api -from .. import RequestFactory -from ...models.fileupload_item import FileuploadItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import FileuploadItem # For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks CHUNK_SIZE = 1024 * 1024 * 5 # 5MB diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 62f910dea..3bca93a7f 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -3,8 +3,8 @@ from .endpoint import QuerysetEndpoint, api from .exceptions import FlowRunFailedException, FlowRunCancelledException -from .. import FlowRunItem, PaginationItem -from ...exponential_backoff import ExponentialBackoffTimer +from tableauserverclient.models import FlowRunItem, PaginationItem +from tableauserverclient.exponential_backoff import ExponentialBackoffTimer logger = logging.getLogger("tableau.endpoint.flowruns") diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 5b182111b..4d97110c4 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -8,18 +8,21 @@ from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union from .dqw_endpoint import _DataQualityWarningEndpoint -from .endpoint import Endpoint, QuerysetEndpoint, api +from .endpoint import QuerysetEndpoint, api from .exceptions import InternalServerError, MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint from .resource_tagger import _ResourceTagger -from .. import RequestFactory, FlowItem, PaginationItem, ConnectionItem -from ...filesys_helpers import ( +from tableauserverclient.models import FlowItem, PaginationItem, ConnectionItem, JobItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.filesys_helpers import ( to_filename, make_download_path, get_file_type, get_file_object_size, ) -from ...models.job_item import JobItem + +io_types_r = (io.BytesIO, io.BufferedReader) +io_types_w = (io.BytesIO, io.BufferedWriter) io_types_r = (io.BytesIO, io.BufferedReader) io_types_w = (io.BytesIO, io.BufferedWriter) diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 289ccdb11..ba5b6649b 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -2,7 +2,8 @@ from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, GroupItem, UserItem, PaginationItem, JobItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import GroupItem, UserItem, PaginationItem, JobItem from ..pager import Pager logger = logging.getLogger("tableau.endpoint.groups") diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 6b709efad..dd210d990 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -2,9 +2,9 @@ from .endpoint import QuerysetEndpoint, api from .exceptions import JobCancelledException, JobFailedException -from .. import JobItem, BackgroundJobItem, PaginationItem +from tableauserverclient.models import JobItem, BackgroundJobItem, PaginationItem from ..request_options import RequestOptionsBase -from ...exponential_backoff import ExponentialBackoffTimer +from tableauserverclient.exponential_backoff import ExponentialBackoffTimer logger = logging.getLogger("tableau.endpoint.jobs") diff --git a/tableauserverclient/server/endpoint/metrics_endpoint.py b/tableauserverclient/server/endpoint/metrics_endpoint.py index fba2632a4..8443726cd 100644 --- a/tableauserverclient/server/endpoint/metrics_endpoint.py +++ b/tableauserverclient/server/endpoint/metrics_endpoint.py @@ -3,11 +3,10 @@ from .permissions_endpoint import _PermissionsEndpoint from .dqw_endpoint import _DataQualityWarningEndpoint from .resource_tagger import _ResourceTagger -from .. import RequestFactory, PaginationItem -from ...models.metric_item import MetricItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import MetricItem, PaginationItem import logging -import copy from typing import List, Optional, TYPE_CHECKING, Tuple diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index e3e9af2a6..e50e32945 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -1,12 +1,12 @@ import logging -from .. import RequestFactory, PermissionsRule +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import TableauItem, PermissionsRule from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError -from ...models import TableauItem -from typing import Optional, Callable, TYPE_CHECKING, List, Union +from typing import Callable, TYPE_CHECKING, List, Optional, Union logger = logging.getLogger(__name__) diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 7ccdcd775..440940606 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -4,7 +4,8 @@ from .endpoint import QuerysetEndpoint, api, XML_CONTENT_TYPE from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .. import RequestFactory, RequestOptions, ProjectItem, PaginationItem, Resource +from tableauserverclient.server import RequestFactory, RequestOptions +from tableauserverclient.models import ProjectItem, PaginationItem, Resource from typing import List, Optional, Tuple, TYPE_CHECKING diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index d5bc4dccb..18c38798e 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -4,8 +4,8 @@ from .endpoint import Endpoint from .exceptions import EndpointUnavailableError, ServerResponseError -from .. import RequestFactory -from ...models.tag_item import TagItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import TagItem logger = logging.getLogger("tableau.endpoint.resource_tagger") diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 3010eeb3a..7cca1f5d5 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -6,7 +6,8 @@ from .endpoint import Endpoint, api, parameter_added_in from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, PaginationItem, ScheduleItem, TaskItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import PaginationItem, ScheduleItem, TaskItem logger = logging.getLogger("tableau.endpoint.schedules") AddResponse = namedtuple("AddResponse", ("result", "error", "warnings", "task_created")) diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index 943aabee6..b396a1f87 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -6,7 +6,7 @@ ServerInfoEndpointNotFoundError, EndpointUnavailableError, ) -from ...models import ServerInfoItem +from tableauserverclient.models import ServerInfoItem logger = logging.getLogger("tableau.endpoint.server_info") @@ -41,5 +41,9 @@ def get(self): raise EndpointUnavailableError(e) raise e - self._info = ServerInfoItem.from_response(server_response.content, self.parent_srv.namespace) + try: + self._info = ServerInfoItem.from_response(server_response.content, self.parent_srv.namespace) + except Exception as e: + logging.getLogger(self.__class__.__name__).debug(e) + logging.getLogger(self.__class__.__name__).debug(server_response.content) return self._info diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 67d7db209..a4c765484 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -3,7 +3,8 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, SiteItem, PaginationItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import SiteItem, PaginationItem logger = logging.getLogger("tableau.endpoint.sites") diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index 6b929524e..a81a2fbf0 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -2,7 +2,8 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, SubscriptionItem, PaginationItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import SubscriptionItem, PaginationItem logger = logging.getLogger("tableau.endpoint.subscriptions") diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index e41ab07ca..e51f885d7 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -4,7 +4,8 @@ from .endpoint import api, Endpoint from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .. import RequestFactory, TableItem, ColumnItem, PaginationItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import TableItem, ColumnItem, PaginationItem from ..pager import Pager logger = logging.getLogger("tableau.endpoint.tables") diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index a70480b91..b903ac634 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -2,7 +2,8 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError -from .. import TaskItem, PaginationItem, RequestFactory +from tableauserverclient.models import TaskItem, PaginationItem +from tableauserverclient.server import RequestFactory logger = logging.getLogger("tableau.endpoint.tasks") diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 3faf4d173..5a9c74619 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,16 +1,13 @@ import copy import logging -import os -from typing import List, Optional, Tuple, Union +from typing import List, Optional, Tuple from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError, ServerResponseError -from .. import RequestFactory, RequestOptions, UserItem, WorkbookItem, PaginationItem, GroupItem +from tableauserverclient.server import RequestFactory, RequestOptions +from tableauserverclient.models import UserItem, WorkbookItem, PaginationItem, GroupItem from ..pager import Pager -# duplicate defined in workbooks_endpoint -FilePath = Union[str, os.PathLike] - logger = logging.getLogger("tableau.endpoint.users") diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 06cc08349..c060298ba 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -5,7 +5,7 @@ from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint from .resource_tagger import _ResourceTagger -from .. import ViewItem, PaginationItem +from tableauserverclient.models import ViewItem, PaginationItem logger = logging.getLogger("tableau.endpoint.views") diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index b28f3e5f1..69a958988 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -1,8 +1,8 @@ import logging from .endpoint import Endpoint, api -from .. import RequestFactory -from ...models import WebhookItem, PaginationItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import WebhookItem, PaginationItem logger = logging.getLogger("tableau.endpoint.webhooks") diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index b7df3fcbb..295a4941f 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -5,29 +5,21 @@ import os from contextlib import closing from pathlib import Path -from typing import ( - List, - Optional, - Sequence, - Tuple, - TYPE_CHECKING, - Union, -) from .endpoint import QuerysetEndpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError -from ...helpers import redact_xml from .permissions_endpoint import _PermissionsEndpoint from .resource_tagger import _ResourceTagger -from .. import RequestFactory, WorkbookItem, ConnectionItem, ViewItem, PaginationItem -from ...filesys_helpers import ( + +from tableauserverclient.filesys_helpers import ( to_filename, make_download_path, get_file_type, get_file_object_size, ) -from ...models.job_item import JobItem -from ...models.revision_item import RevisionItem +from tableauserverclient.helpers import redact_xml +from tableauserverclient.models import WorkbookItem, ConnectionItem, ViewItem, PaginationItem, JobItem, RevisionItem +from tableauserverclient.server import RequestFactory from typing import ( List, @@ -39,10 +31,9 @@ ) if TYPE_CHECKING: - from ..server import Server - from ..request_options import RequestOptions - from .. import DatasourceItem - from ...models.connection_credentials import ConnectionCredentials + from tableauserverclient.server import Server + from tableauserverclient.server.request_options import RequestOptions + from tableauserverclient.models import DatasourceItem, ConnectionCredentials from .schedules_endpoint import AddResponse io_types_r = (io.BytesIO, io.BufferedReader) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 720eb4085..b19c3cc56 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,29 +1,12 @@ -from os import name import xml.etree.ElementTree as ET from typing import Any, Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata -from tableauserverclient.models.metric_item import MetricItem - -from ..models import ConnectionCredentials -from ..models import ConnectionItem -from ..models import DataAlertItem -from ..models import FlowItem -from ..models import ProjectItem -from ..models import SiteItem -from ..models import SubscriptionItem -from ..models import TaskItem, UserItem, GroupItem, PermissionsRule, FavoriteItem -from ..models import WebhookItem +from tableauserverclient.models import * if TYPE_CHECKING: - from ..models import SubscriptionItem - from ..models import DataAlertItem - from ..models import FlowItem - from ..models import ConnectionItem - from ..models import SiteItem - from ..models import ProjectItem from tableauserverclient.server import Server @@ -1019,6 +1002,8 @@ def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") connection_element.attrib["password"] = connection_item.password if connection_item.embed_password is not None: connection_element.attrib["embedPassword"] = str(connection_item.embed_password).lower() + if connection_item.query_tagging is not None: + connection_element.attrib["queryTaggingEnabled"] = str(connection_item.query_tagging).lower() class TaskRequest(object): @@ -1144,10 +1129,21 @@ def update_req(self, xml_request: ET.Element, metric_item: MetricItem) -> bytes: return ET.tostring(xml_request) +class CustomViewRequest(object): + @_tsrequest_wrapped + def update_req(self, xml_request: ET.Element, custom_view_item: CustomViewItem): + updating_element = ET.SubElement(xml_request, "customView") + if custom_view_item.owner is not None and custom_view_item.owner.id is not None: + ET.SubElement(updating_element, "owner", {"id": custom_view_item.owner.id}) + if custom_view_item.name is not None: + updating_element.attrib["name"] = custom_view_item.name + + class RequestFactory(object): Auth = AuthRequest() Connection = Connection() Column = ColumnRequest() + CustomView = CustomViewRequest() DataAlert = DataAlertRequest() Datasource = DatasourceRequest() Database = DatabaseRequest() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index f4ed8fd3c..baedd74de 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,4 +1,4 @@ -from ..models.property_decorators import property_is_int +from tableauserverclient.models.property_decorators import property_is_int import logging logger = logging.getLogger("tableau.request_options") diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index d2a8b933b..887b9de6d 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,11 +1,12 @@ import logging -import warnings import requests import urllib3 from defusedxml.ElementTree import fromstring, ParseError from packaging.version import Version + +from . import CustomViews from .endpoint import ( Sites, Views, @@ -48,8 +49,9 @@ "9.1": "2.0", "9.0": "2.0", } + minimum_supported_server_version = "2.3" -default_server_version = "2.3" +default_server_version = "2.4" # first version that dropped the legacy auth endpoint class Server(object): @@ -95,6 +97,9 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self._namespace = Namespace() self.flow_runs = FlowRuns(self) self.metrics = Metrics(self) + self.custom_views = CustomViews(self) + + self.logger = logging.getLogger("TSC.server") self._session = self._session_factory() self._http_options = dict() # must set this before making a server call @@ -110,11 +115,14 @@ def __init__(self, server_address, use_server_version=False, http_options=None, def validate_connection_settings(self): try: Endpoint(self).set_parameters(self._http_options, None, None, None, None) + if not self._server_address.startswith("http://") and not self._server_address.startswith("https://"): + self._server_address = "http://" + self._server_address + self._session.prepare_request(requests.Request("GET", url=self._server_address, params=self._http_options)) except Exception as req_ex: raise ValueError("Server connection settings not valid", req_ex) def __repr__(self): - return " [Connection: {}, {}]".format(self.baseurl, self.server_info.serverInfo) + return "".format(self.baseurl, self.server_info.serverInfo) def add_http_options(self, options_dict: dict): try: @@ -122,8 +130,7 @@ def add_http_options(self, options_dict: dict): if "verify" in options_dict.keys() and self._http_options.get("verify") is False: urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # would be nice if you could turn them back on - except BaseException as be: - print(be) + except Exception as be: # expected errors on invalid input: # 'set' object has no attribute 'keys', 'list' object has no attribute 'keys' # TypeError: cannot convert dictionary update sequence element #0 to a sequence (input is a tuple) @@ -144,43 +151,43 @@ def _set_auth(self, site_id, user_id, auth_token): self._auth_token = auth_token def _get_legacy_version(self): - dest = Endpoint(self) - response = dest._make_request(method=self.session.get, url=self.server_address + "/auth?format=xml") + # the serverInfo call was introduced in 2.4, earlier than that we have this different call + response = self._session.get(self.server_address + "/auth?format=xml") try: info_xml = fromstring(response.content) except ParseError as parseError: - logging.getLogger("TSC.server").info(parseError) - logging.getLogger("TSC.server").info( - "Could not read server version info. The server may not be running or configured." - ) + self.logger.info(parseError) + self.logger.info("Could not read server version info. The server may not be running or configured.") return self.version prod_version = info_xml.find(".//product_version").text - version = _PRODUCT_TO_REST_VERSION.get(prod_version, "2.1") # 2.1 + version = _PRODUCT_TO_REST_VERSION.get(prod_version, minimum_supported_server_version) return version def _determine_highest_version(self): try: old_version = self.version - self.version = "2.4" version = self.server_info.get().rest_api_version - except ServerInfoEndpointNotFoundError: + except ServerInfoEndpointNotFoundError as e: + self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = self._get_legacy_version() - except BaseException: + except EndpointUnavailableError as e: + self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = self._get_legacy_version() - - self.version = old_version - - return version + except Exception as e: + self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + version = None + self.logger.info("versions: {}, {}".format(version, old_version)) + return version or old_version def use_server_version(self): self.version = self._determine_highest_version() def use_highest_version(self): self.use_server_version() - warnings.warn("use use_server_version instead", DeprecationWarning) + self.logger.info("use use_server_version instead", DeprecationWarning) def check_at_least_version(self, target: str): - server_version = Version(self.version or "0.0") + server_version = Version(self.version or "2.4") target_version = Version(target) return server_version >= target_version diff --git a/test/assets/custom_view_get.xml b/test/assets/custom_view_get.xml new file mode 100644 index 000000000..67e342f30 --- /dev/null +++ b/test/assets/custom_view_get.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/test/assets/custom_view_get_id.xml b/test/assets/custom_view_get_id.xml new file mode 100644 index 000000000..14e589b8d --- /dev/null +++ b/test/assets/custom_view_get_id.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/assets/custom_view_update.xml b/test/assets/custom_view_update.xml new file mode 100644 index 000000000..5ab85bc05 --- /dev/null +++ b/test/assets/custom_view_update.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/assets/server_info_get.xml b/test/assets/server_info_get.xml index ce4e0b322..94218502a 100644 --- a/test/assets/server_info_get.xml +++ b/test/assets/server_info_get.xml @@ -1,6 +1,6 @@ 10.1.0 -2.4 +3.10 - \ No newline at end of file + diff --git a/test/http/test_http_requests.py b/test/http/test_http_requests.py index bf9292dec..ce845502d 100644 --- a/test/http/test_http_requests.py +++ b/test/http/test_http_requests.py @@ -11,6 +11,8 @@ def mocked_requests_get(*args, **kwargs): class MockResponse: def __init__(self, status_code): + self.headers = {} + self.encoding = None self.content = ( "" "" @@ -43,9 +45,9 @@ def test_init_server_model_valid_https_server_name_works(self): def test_init_server_model_bad_server_name_not_version_check(self): server = TSC.Server("fake-url", use_server_version=False) - def test_init_server_model_bad_server_name_do_version_check(self): - with self.assertRaises(requests.exceptions.ConnectionError): - server = TSC.Server("fake-url", use_server_version=True) + @mock.patch("requests.sessions.Session.get", side_effect=mocked_requests_get) + def test_init_server_model_bad_server_name_do_version_check(self, mock_get): + server = TSC.Server("fake-url", use_server_version=True) def test_init_server_model_bad_server_name_not_version_check_random_options(self): # with self.assertRaises(MissingSchema): diff --git a/test/models/_models.py b/test/models/_models.py new file mode 100644 index 000000000..a1630da9c --- /dev/null +++ b/test/models/_models.py @@ -0,0 +1,61 @@ +from tableauserverclient import * + +# mmm. why aren't these available in the tsc namespace? +from tableauserverclient.models import ( + DataAccelerationReportItem, + FavoriteItem, + Credentials, + ServerInfoItem, + Resource, + TableauItem, + plural_type, +) + + +def get_defined_models(): + # not clever: copied from tsc/models/__init__.py + return [ + ColumnItem, + ConnectionCredentials, + ConnectionItem, + DataAccelerationReportItem, + DataAlertItem, + DatabaseItem, + DatasourceItem, + DQWItem, + UnpopulatedPropertyError, + FavoriteItem, + FlowItem, + FlowRunItem, + GroupItem, + IntervalItem, + DailyInterval, + WeeklyInterval, + MonthlyInterval, + HourlyInterval, + JobItem, + BackgroundJobItem, + MetricItem, + PaginationItem, + PermissionsRule, + Permission, + ProjectItem, + RevisionItem, + ScheduleItem, + ServerInfoItem, + SiteItem, + SubscriptionItem, + TableItem, + Credentials, + TableauAuth, + PersonalAccessTokenAuth, + Resource, + TableauItem, + plural_type, + Target, + TaskItem, + UserItem, + ViewItem, + WebhookItem, + WorkbookItem, + ] diff --git a/test/models/test_repr.py b/test/models/test_repr.py new file mode 100644 index 000000000..f3da9fde2 --- /dev/null +++ b/test/models/test_repr.py @@ -0,0 +1,40 @@ +import pytest + +from unittest import TestCase +import _models + + +# ensure that all models have a __repr__ method implemented +class TestAllModels(TestCase): + + """ + ColumnItem wrapper_descriptor + ConnectionCredentials wrapper_descriptor + DataAccelerationReportItem wrapper_descriptor + DatabaseItem wrapper_descriptor + DQWItem wrapper_descriptor + UnpopulatedPropertyError wrapper_descriptor + FavoriteItem wrapper_descriptor + FlowRunItem wrapper_descriptor + IntervalItem wrapper_descriptor + DailyInterval wrapper_descriptor + WeeklyInterval wrapper_descriptor + MonthlyInterval wrapper_descriptor + HourlyInterval wrapper_descriptor + BackgroundJobItem wrapper_descriptor + PaginationItem wrapper_descriptor + Permission wrapper_descriptor + ServerInfoItem wrapper_descriptor + SiteItem wrapper_descriptor + TableItem wrapper_descriptor + Resource wrapper_descriptor + """ + + # not all models have __repr__ yet: see above list + @pytest.mark.xfail() + def test_repr_is_implemented(self): + m = _models.get_defined_models() + for model in m: + with self.subTest(model.__name__, model=model): + print(model.__name__, type(model.__repr__).__name__) + self.assertEqual(type(model.__repr__).__name__, "function") diff --git a/test/test_connection_.py b/test/test_connection_.py new file mode 100644 index 000000000..47b796ebe --- /dev/null +++ b/test/test_connection_.py @@ -0,0 +1,34 @@ +import unittest +import tableauserverclient as TSC + + +class DatasourceModelTests(unittest.TestCase): + def test_require_boolean_query_tag_fails(self): + conn = TSC.ConnectionItem() + conn._connection_type = "postgres" + with self.assertRaises(ValueError): + conn.query_tagging = "no" + + def test_set_query_tag_normal_conn(self): + conn = TSC.ConnectionItem() + conn._connection_type = "postgres" + conn.query_tagging = True + self.assertEqual(conn.query_tagging, True) + + def test_ignore_query_tag_for_hyper(self): + conn = TSC.ConnectionItem() + conn._connection_type = "hyper" + conn.query_tagging = True + self.assertEqual(conn.query_tagging, None) + + def test_ignore_query_tag_for_teradata(self): + conn = TSC.ConnectionItem() + conn._connection_type = "teradata" + conn.query_tagging = True + self.assertEqual(conn.query_tagging, None) + + def test_ignore_query_tag_for_snowflake(self): + conn = TSC.ConnectionItem() + conn._connection_type = "snowflake" + conn.query_tagging = True + self.assertEqual(conn.query_tagging, None) diff --git a/test/test_custom_view.py b/test/test_custom_view.py new file mode 100644 index 000000000..c1fe8c407 --- /dev/null +++ b/test/test_custom_view.py @@ -0,0 +1,133 @@ +import os +import unittest + +import requests_mock + +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime + +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") + +GET_XML = os.path.join(TEST_ASSET_DIR, "custom_view_get.xml") +GET_XML_ID = os.path.join(TEST_ASSET_DIR, "custom_view_get_id.xml") +POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, "Sample View Image.png") +CUSTOM_VIEW_UPDATE_XML = os.path.join(TEST_ASSET_DIR, "custom_view_update.xml") + + +class CustomViewTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server("http://test", False) + self.server.version = "3.19" # custom views only introduced in 3.19 + + # Fake sign in + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + self.baseurl = self.server.custom_views.baseurl + + def test_get(self) -> None: + with open(GET_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + print(response_xml) + with requests_mock.mock() as m: + m.get(self.baseurl, text=response_xml) + all_views, pagination_item = self.server.custom_views.get() + + self.assertEqual(2, pagination_item.total_available) + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", all_views[0].id) + self.assertEqual("ENDANGERED SAFARI", all_views[0].name) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", all_views[0].content_url) + self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", all_views[0].workbook.id) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[0].owner.id) + self.assertIsNone(all_views[0].created_at) + self.assertIsNone(all_views[0].updated_at) + + self.assertEqual("fd252f73-593c-4c4e-8584-c032b8022adc", all_views[1].id) + self.assertEqual("Overview", all_views[1].name) + self.assertEqual(False, all_views[1].shared) + self.assertEqual("6d13b0ca-043d-4d42-8c9d-3f3313ea3a00", all_views[1].workbook.id) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[1].owner.id) + self.assertEqual("2002-05-30T09:00:00Z", format_datetime(all_views[1].created_at)) + self.assertEqual("2002-06-05T08:00:59Z", format_datetime(all_views[1].updated_at)) + + def test_get_by_id(self) -> None: + with open(GET_XML_ID, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5", text=response_xml) + view: TSC.CustomViewItem = self.server.custom_views.get_by_id("d79634e1-6063-4ec9-95ff-50acbf609ff5") + + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", view.id) + self.assertEqual("ENDANGERED SAFARI", view.name) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", view.content_url) + if view.workbook: + self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", view.workbook.id) + if view.owner: + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", view.owner.id) + if view.view: + self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", view.view.id) + self.assertEqual("2002-05-30T09:00:00Z", format_datetime(view.created_at)) + self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at)) + + def test_get_by_id_missing_id(self) -> None: + self.assertRaises(TSC.MissingRequiredFieldError, self.server.custom_views.get_by_id, None) + + def test_get_before_signin(self) -> None: + self.server._auth_token = None + self.assertRaises(TSC.NotSignedInError, self.server.custom_views.get) + + def test_populate_image(self) -> None: + with open(POPULATE_PREVIEW_IMAGE, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image", content=response) + single_view = TSC.CustomViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + self.server.custom_views.populate_image(single_view) + self.assertEqual(response, single_view.image) + + def test_populate_image_with_options(self) -> None: + with open(POPULATE_PREVIEW_IMAGE, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get( + self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?resolution=high&maxAge=10", content=response + ) + single_view = TSC.CustomViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High, maxage=10) + self.server.custom_views.populate_image(single_view, req_option) + self.assertEqual(response, single_view.image) + + def test_populate_image_missing_id(self) -> None: + single_view = TSC.CustomViewItem() + single_view._id = None + self.assertRaises(TSC.MissingRequiredFieldError, self.server.custom_views.populate_image, single_view) + + def test_delete(self) -> None: + with requests_mock.mock() as m: + m.delete(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42", status_code=204) + self.server.custom_views.delete("3cc6cd06-89ce-4fdc-b935-5294135d6d42") + + def test_delete_missing_id(self) -> None: + self.assertRaises(ValueError, self.server.custom_views.delete, "") + + def test_update(self) -> None: + with open(CUSTOM_VIEW_UPDATE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + the_custom_view = TSC.CustomViewItem("1d0304cd-3796-429f-b815-7258370b9b74", name="Best test ever") + the_custom_view._id = "1f951daf-4061-451a-9df1-69a8062664f2" + the_custom_view.owner = TSC.UserItem() + the_custom_view.owner.id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + the_custom_view = self.server.custom_views.update(the_custom_view) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", the_custom_view.id) + if the_custom_view.owner: + self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", the_custom_view.owner.id) + self.assertEqual("Best test ever", the_custom_view.name) + + def test_update_missing_id(self) -> None: + cv = TSC.CustomViewItem(name="test") + self.assertRaises(TSC.MissingRequiredFieldError, self.server.custom_views.update, cv) diff --git a/test/test_datasource_model.py b/test/test_datasource_model.py index 81a26b068..2360574ec 100644 --- a/test/test_datasource_model.py +++ b/test/test_datasource_model.py @@ -1,5 +1,4 @@ import unittest - import tableauserverclient as TSC @@ -9,3 +8,13 @@ def test_invalid_project_id(self): datasource = TSC.DatasourceItem("10") with self.assertRaises(ValueError): datasource.project_id = None + + def test_require_boolean_flag_bridge_fail(self): + datasource = TSC.DatasourceItem("10") + with self.assertRaises(ValueError): + datasource.use_remote_query_agent = "yes" + + def test_require_boolean_flag_bridge_ok(self): + datasource = TSC.DatasourceItem("10") + datasource.use_remote_query_agent = True + self.assertEqual(datasource.use_remote_query_agent, True) diff --git a/test/test_server_info.py b/test/test_server_info.py index 80b071e75..1cf190ecd 100644 --- a/test/test_server_info.py +++ b/test/test_server_info.py @@ -28,7 +28,7 @@ def test_server_info_get(self): self.assertEqual("10.1.0", actual.product_version) self.assertEqual("10100.16.1024.2100", actual.build_number) - self.assertEqual("2.4", actual.rest_api_version) + self.assertEqual("3.10", actual.rest_api_version) def test_server_info_use_highest_version_downgrades(self): with open(SERVER_INFO_AUTH_INFO_XML, "rb") as f: @@ -42,18 +42,19 @@ def test_server_info_use_highest_version_downgrades(self): m.get(self.server.server_address + "/api/2.4/serverInfo", text=si_response_xml, status_code=404) m.get(self.server.server_address + "/auth?format=xml", text=auth_response_xml) self.server.use_server_version() + # does server-version[9.2] lookup in PRODUCT_TO_REST_VERSION self.assertEqual(self.server.version, "2.2") def test_server_info_use_highest_version_upgrades(self): with open(SERVER_INFO_GET_XML, "rb") as f: si_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.server.server_address + "/api/2.4/serverInfo", text=si_response_xml) + m.get(self.server.server_address + "/api/2.8/serverInfo", text=si_response_xml) # Pretend we're old - self.server.version = "2.0" + self.server.version = "2.8" self.server.use_server_version() - # Did we upgrade to 2.4? - self.assertEqual(self.server.version, "2.4") + # Did we upgrade to 3.10? + self.assertEqual(self.server.version, "3.10") def test_server_use_server_version_flag(self): with open(SERVER_INFO_25_XML, "rb") as f: diff --git a/test/test_user_model.py b/test/test_user_model.py index fcb9b7f90..d0997b9ff 100644 --- a/test/test_user_model.py +++ b/test/test_user_model.py @@ -10,16 +10,6 @@ class UserModelTests(unittest.TestCase): - def test_invalid_name(self): - self.assertRaises(ValueError, TSC.UserItem, None, TSC.UserItem.Roles.Publisher) - self.assertRaises(ValueError, TSC.UserItem, "", TSC.UserItem.Roles.Publisher) - user = TSC.UserItem("me", TSC.UserItem.Roles.Publisher) - with self.assertRaises(ValueError): - user.name = None - - with self.assertRaises(ValueError): - user.name = "" - def test_invalid_auth_setting(self): user = TSC.UserItem("me", TSC.UserItem.Roles.Publisher) with self.assertRaises(ValueError): diff --git a/test/test_workbook.py b/test/test_workbook.py index 8711ba15e..5114ce1b8 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -1,20 +1,15 @@ import os import re import requests_mock -import tableauserverclient as TSC import tempfile import unittest -import xml.etree.ElementTree as ET - from defusedxml.ElementTree import fromstring from io import BytesIO from pathlib import Path import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime -from tableauserverclient.models.group_item import GroupItem -from tableauserverclient.models.permissions_item import PermissionsRule -from tableauserverclient.models.user_item import UserItem +from tableauserverclient.models import UserItem, GroupItem, PermissionsRule from tableauserverclient.server.endpoint.exceptions import InternalServerError from tableauserverclient.server.request_factory import RequestFactory from ._utils import asset