diff --git a/.github/workflows/slack.yml b/.github/workflows/slack.yml index 2ecb0be7f..9afebf25b 100644 --- a/.github/workflows/slack.yml +++ b/.github/workflows/slack.yml @@ -5,7 +5,7 @@ on: [push, pull_request, issues] jobs: slack-notifications: continue-on-error: true - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest name: Sends a message to Slack when a push, a pull request or an issue is made steps: - name: Send message to Slack API diff --git a/samples/extracts.py b/samples/extracts.py index 8e7a66aac..d9289452a 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -42,7 +42,6 @@ def main(): server.add_http_options({"verify": False}) server.use_server_version() with server.auth.sign_in(tableau_auth): - wb = None ds = None if args.workbook: diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 39f8267a8..21e2c4760 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -25,6 +25,7 @@ LinkedTaskItem, LinkedTaskStepItem, LinkedTaskFlowRunItem, + LocationItem, MetricItem, MonthlyInterval, PaginationItem, @@ -35,6 +36,7 @@ Resource, RevisionItem, ScheduleItem, + SiteAuthConfiguration, SiteItem, ServerInfoItem, SubscriptionItem, @@ -56,6 +58,7 @@ ExcelRequestOptions, ImageRequestOptions, PDFRequestOptions, + PPTXRequestOptions, RequestOptions, MissingRequiredFieldError, FailedSignInError, @@ -100,6 +103,7 @@ "LinkedTaskFlowRunItem", "LinkedTaskItem", "LinkedTaskStepItem", + "LocationItem", "MetricItem", "MissingRequiredFieldError", "MonthlyInterval", @@ -107,6 +111,7 @@ "Pager", "PaginationItem", "PDFRequestOptions", + "PPTXRequestOptions", "Permission", "PermissionsRule", "PersonalAccessTokenAuth", @@ -119,6 +124,7 @@ "ServerInfoItem", "ServerResponseError", "SiteItem", + "SiteAuthConfiguration", "Sort", "SubscriptionItem", "TableauAuth", diff --git a/tableauserverclient/helpers/strings.py b/tableauserverclient/helpers/strings.py index 75534103b..6ba4e48d9 100644 --- a/tableauserverclient/helpers/strings.py +++ b/tableauserverclient/helpers/strings.py @@ -1,6 +1,6 @@ from defusedxml.ElementTree import fromstring, tostring from functools import singledispatch -from typing import TypeVar +from typing import TypeVar, overload # the redact method can handle either strings or bytes, but it can't mix them. @@ -41,3 +41,27 @@ def _(xml: str) -> str: @redact_xml.register # type: ignore[no-redef] def _(xml: bytes) -> bytes: return _redact_any_type(bytearray(xml), b"password", b"..[redacted]") + + +@overload +def nullable_str_to_int(value: None) -> None: ... + + +@overload +def nullable_str_to_int(value: str) -> int: ... + + +def nullable_str_to_int(value): + return int(value) if value is not None else None + + +@overload +def nullable_str_to_bool(value: None) -> None: ... + + +@overload +def nullable_str_to_bool(value: str) -> bool: ... + + +def nullable_str_to_bool(value): + return str(value).lower() == "true" if value is not None else None diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index e4131b720..30cd88104 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -28,6 +28,7 @@ LinkedTaskStepItem, LinkedTaskFlowRunItem, ) +from tableauserverclient.models.location_item import LocationItem from tableauserverclient.models.metric_item import MetricItem from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.models.permissions_item import PermissionsRule, Permission @@ -35,7 +36,7 @@ from tableauserverclient.models.revision_item import RevisionItem from tableauserverclient.models.schedule_item import ScheduleItem from tableauserverclient.models.server_info_item import ServerInfoItem -from tableauserverclient.models.site_item import SiteItem +from tableauserverclient.models.site_item import SiteItem, SiteAuthConfiguration from tableauserverclient.models.subscription_item import SubscriptionItem from tableauserverclient.models.table_item import TableItem from tableauserverclient.models.tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth, JWTAuth @@ -48,6 +49,7 @@ from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem from tableauserverclient.models.webhook_item import WebhookItem from tableauserverclient.models.workbook_item import WorkbookItem +from tableauserverclient.models.extract_item import ExtractItem __all__ = [ "ColumnItem", @@ -75,6 +77,7 @@ "MonthlyInterval", "HourlyInterval", "BackgroundJobItem", + "LocationItem", "MetricItem", "PaginationItem", "Permission", @@ -83,6 +86,7 @@ "RevisionItem", "ScheduleItem", "ServerInfoItem", + "SiteAuthConfiguration", "SiteItem", "SubscriptionItem", "TableItem", @@ -103,4 +107,5 @@ "LinkedTaskItem", "LinkedTaskStepItem", "LinkedTaskFlowRunItem", + "ExtractItem", ] diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index e68958c3b..6a8244fb1 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -103,11 +103,11 @@ def from_response(cls, resp, ns) -> list["ConnectionItem"]: all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) for connection_xml in all_connection_xml: connection_item = cls() - connection_item._id = connection_xml.get("id", None) + connection_item._id = connection_xml.get("id", connection_xml.get("connectionId", None)) connection_item._connection_type = connection_xml.get("type", connection_xml.get("dbClass", None)) connection_item.embed_password = string_to_bool(connection_xml.get("embedPassword", "")) - connection_item.server_address = connection_xml.get("serverAddress", None) - connection_item.server_port = connection_xml.get("serverPort", None) + connection_item.server_address = connection_xml.get("serverAddress", connection_xml.get("server", None)) + connection_item.server_port = connection_xml.get("serverPort", connection_xml.get("port", None)) connection_item.username = connection_xml.get("userName", None) connection_item._query_tagging = ( string_to_bool(s) if (s := connection_xml.get("queryTagging", None)) else None diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 1b082c157..5501ee332 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -6,9 +6,11 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.helpers.strings import nullable_str_to_bool, nullable_str_to_int from tableauserverclient.models.connection_item import ConnectionItem from tableauserverclient.models.exceptions import UnpopulatedPropertyError from tableauserverclient.models.permissions_item import PermissionsRule +from tableauserverclient.models.project_item import ProjectItem from tableauserverclient.models.property_decorators import ( property_not_nullable, property_is_boolean, @@ -16,9 +18,118 @@ ) from tableauserverclient.models.revision_item import RevisionItem from tableauserverclient.models.tag_item import TagItem +from tableauserverclient.models.user_item import UserItem class DatasourceItem: + """ + Represents a Tableau datasource item. + + Parameters + ---------- + project_id : Optional[str] + The project ID that the datasource belongs to. + + name : Optional[str] + The name of the datasource. + + Attributes + ---------- + ask_data_enablement : Optional[str] + Determines if a data source allows use of Ask Data. The value can be + TSC.DatasourceItem.AskDataEnablement.Enabled, + TSC.DatasourceItem.AskDataEnablement.Disabled, or + TSC.DatasourceItem.AskDataEnablement.SiteDefault. If no setting is + specified, it will default to SiteDefault. See REST API Publish + Datasource for more information about ask_data_enablement. + + connected_workbooks_count : Optional[int] + The number of workbooks connected to the datasource. + + connections : list[ConnectionItem] + The list of data connections (ConnectionItem) for the specified data + source. You must first call the populate_connections method to access + this data. See the ConnectionItem class. + + content_url : Optional[str] + The name of the data source as it would appear in a URL. + + created_at : Optional[datetime.datetime] + The time the data source was created. + + certified : Optional[bool] + A Boolean value that indicates whether the data source is certified. + + certification_note : Optional[str] + The optional note that describes the certified data source. + + datasource_type : Optional[str] + The type of data source, for example, sqlserver or excel-direct. + + description : Optional[str] + The description for the data source. + + encrypt_extracts : Optional[bool] + A Boolean value to determine if a datasource should be encrypted or not. + See Extract and Encryption Methods for more information. + + favorites_total : Optional[int] + The number of users who have marked the data source as a favorite. + + has_alert : Optional[bool] + A Boolean value that indicates whether the data source has an alert. + + has_extracts : Optional[bool] + A Boolean value that indicates whether the datasource has extracts. + + id : Optional[str] + The identifier for the data source. You need this value to query a + specific data source or to delete a data source with the get_by_id and + delete methods. + + is_published : Optional[bool] + A Boolean value that indicates whether the data source is published. + + name : Optional[str] + The name of the data source. If not specified, the name of the published + data source file is used. + + owner: Optional[UserItem] + The owner of the data source. + + owner_id : Optional[str] + The identifier of the owner of the data source. + + project : Optional[ProjectItem] + The project that the data source belongs to. + + project_id : Optional[str] + The identifier of the project associated with the data source. You must + provide this identifier when you create an instance of a DatasourceItem. + + project_name : Optional[str] + The name of the project associated with the data source. + + server_name : Optional[str] + The name of the server where the data source is published. + + tags : Optional[set[str]] + The tags (list of strings) that have been added to the data source. + + updated_at : Optional[datetime.datetime] + The date and time when the data source was last updated. + + use_remote_query_agent : Optional[bool] + A Boolean value that indicates whether to allow or disallow your Tableau + Cloud site to use Tableau Bridge clients. Bridge allows you to maintain + data sources with live connections to supported on-premises data + sources. See Configure and Manage the Bridge Client Pool for more + information. + + webpage_url : Optional[str] + The url of the datasource as displayed in browsers. + """ + class AskDataEnablement: Enabled = "Enabled" Disabled = "Disabled" @@ -33,29 +144,36 @@ def __repr__(self): ) def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) -> None: - self._ask_data_enablement = None - self._certified = None - self._certification_note = None - self._connections = None + self._ask_data_enablement: Optional[str] = None + self._certified: Optional[bool] = None + self._certification_note: Optional[str] = None + self._connections: Optional[list[ConnectionItem]] = None self._content_url: Optional[str] = None - self._created_at = None - self._datasource_type = None - self._description = None - self._encrypt_extracts = None - self._has_extracts = None + self._created_at: Optional[datetime.datetime] = None + self._datasource_type: Optional[str] = None + self._description: Optional[str] = None + self._encrypt_extracts: Optional[bool] = None + self._has_extracts: Optional[bool] = None self._id: Optional[str] = None self._initial_tags: set = set() self._project_name: Optional[str] = None self._revisions = None self._size: Optional[int] = None - self._updated_at = None - self._use_remote_query_agent = None - self._webpage_url = None - self.description = None - self.name = name + self._updated_at: Optional[datetime.datetime] = None + self._use_remote_query_agent: Optional[bool] = None + self._webpage_url: Optional[str] = None + self.description: Optional[str] = None + self.name: Optional[str] = name self.owner_id: Optional[str] = None - self.project_id = project_id + self.project_id: Optional[str] = project_id self.tags: set[str] = set() + self._connected_workbooks_count: Optional[int] = None + self._favorites_total: Optional[int] = None + self._has_alert: Optional[bool] = None + self._is_published: Optional[bool] = None + self._server_name: Optional[str] = None + self._project: Optional[ProjectItem] = None + self._owner: Optional[UserItem] = None self._permissions = None self._data_quality_warnings = None @@ -63,16 +181,16 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) return None @property - def ask_data_enablement(self) -> Optional[AskDataEnablement]: + def ask_data_enablement(self) -> Optional[str]: return self._ask_data_enablement @ask_data_enablement.setter @property_is_enum(AskDataEnablement) - def ask_data_enablement(self, value: Optional[AskDataEnablement]): + def ask_data_enablement(self, value: Optional[str]): self._ask_data_enablement = value @property - def connections(self) -> Optional[list[ConnectionItem]]: + def connections(self): if self._connections is None: error = "Datasource item must be populated with connections first." raise UnpopulatedPropertyError(error) @@ -112,7 +230,7 @@ def certification_note(self, value: Optional[str]): self._certification_note = value @property - def encrypt_extracts(self): + def encrypt_extracts(self) -> Optional[bool]: return self._encrypt_extracts @encrypt_extracts.setter @@ -156,7 +274,7 @@ def description(self) -> Optional[str]: return self._description @description.setter - def description(self, value: str): + def description(self, value: Optional[str]): self._description = value @property @@ -187,14 +305,42 @@ def revisions(self) -> list[RevisionItem]: def size(self) -> Optional[int]: return self._size - def _set_connections(self, connections): + @property + def connected_workbooks_count(self) -> Optional[int]: + return self._connected_workbooks_count + + @property + def favorites_total(self) -> Optional[int]: + return self._favorites_total + + @property + def has_alert(self) -> Optional[bool]: + return self._has_alert + + @property + def is_published(self) -> Optional[bool]: + return self._is_published + + @property + def server_name(self) -> Optional[str]: + return self._server_name + + @property + def project(self) -> Optional[ProjectItem]: + return self._project + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + + def _set_connections(self, connections) -> None: self._connections = connections def _set_permissions(self, permissions): self._permissions = permissions - def _set_data_quality_warnings(self, dqws): - self._data_quality_warnings = dqws + def _set_data_quality_warnings(self, dqw): + self._data_quality_warnings = dqw def _set_revisions(self, revisions): self._revisions = revisions @@ -223,6 +369,13 @@ def _parse_common_elements(self, datasource_xml, ns): use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ) = self._parse_element(datasource_xml, ns) self._set_values( ask_data_enablement, @@ -244,6 +397,13 @@ def _parse_common_elements(self, datasource_xml, ns): use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ) return self @@ -268,6 +428,13 @@ def _set_values( use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ): if ask_data_enablement is not None: self._ask_data_enablement = ask_data_enablement @@ -307,6 +474,20 @@ def _set_values( self._webpage_url = webpage_url if size is not None: self._size = int(size) + if connected_workbooks_count is not None: + self._connected_workbooks_count = connected_workbooks_count + if favorites_total is not None: + self._favorites_total = favorites_total + if has_alert is not None: + self._has_alert = has_alert + if is_published is not None: + self._is_published = is_published + if server_name is not None: + self._server_name = server_name + if project is not None: + self._project = project + if owner is not None: + self._owner = owner @classmethod def from_response(cls, resp: str, ns: dict) -> list["DatasourceItem"]: @@ -341,6 +522,11 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: use_remote_query_agent = datasource_xml.get("useRemoteQueryAgent", None) webpage_url = datasource_xml.get("webpageUrl", None) size = datasource_xml.get("size", None) + connected_workbooks_count = nullable_str_to_int(datasource_xml.get("connectedWorkbooksCount", None)) + favorites_total = nullable_str_to_int(datasource_xml.get("favoritesTotal", None)) + has_alert = nullable_str_to_bool(datasource_xml.get("hasAlert", None)) + is_published = nullable_str_to_bool(datasource_xml.get("isPublished", None)) + server_name = datasource_xml.get("serverName", None) tags = None tags_elem = datasource_xml.find(".//t:tags", namespaces=ns) @@ -351,12 +537,14 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: project_name = None project_elem = datasource_xml.find(".//t:project", namespaces=ns) if project_elem is not None: + project = ProjectItem.from_xml(project_elem, ns) project_id = project_elem.get("id", None) project_name = project_elem.get("name", None) owner_id = None owner_elem = datasource_xml.find(".//t:owner", namespaces=ns) if owner_elem is not None: + owner = UserItem.from_xml(owner_elem, ns) owner_id = owner_elem.get("id", None) ask_data_enablement = None @@ -384,4 +572,11 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ) diff --git a/tableauserverclient/models/extract_item.py b/tableauserverclient/models/extract_item.py new file mode 100644 index 000000000..7562ffdde --- /dev/null +++ b/tableauserverclient/models/extract_item.py @@ -0,0 +1,82 @@ +from typing import Optional, List +from defusedxml.ElementTree import fromstring +import xml.etree.ElementTree as ET + + +class ExtractItem: + """ + An extract refresh task item. + + Attributes + ---------- + id : str + The ID of the extract refresh task + priority : int + The priority of the task + type : str + The type of extract refresh (incremental or full) + workbook_id : str, optional + The ID of the workbook if this is a workbook extract + datasource_id : str, optional + The ID of the datasource if this is a datasource extract + """ + + def __init__( + self, priority: int, refresh_type: str, workbook_id: Optional[str] = None, datasource_id: Optional[str] = None + ): + self._id: Optional[str] = None + self._priority = priority + self._type = refresh_type + self._workbook_id = workbook_id + self._datasource_id = datasource_id + + @property + def id(self) -> Optional[str]: + return self._id + + @property + def priority(self) -> int: + return self._priority + + @property + def type(self) -> str: + return self._type + + @property + def workbook_id(self) -> Optional[str]: + return self._workbook_id + + @property + def datasource_id(self) -> Optional[str]: + return self._datasource_id + + @classmethod + def from_response(cls, resp: str, ns: dict) -> List["ExtractItem"]: + """Create ExtractItem objects from XML response.""" + parsed_response = fromstring(resp) + return cls.from_xml_element(parsed_response, ns) + + @classmethod + def from_xml_element(cls, parsed_response: ET.Element, ns: dict) -> List["ExtractItem"]: + """Create ExtractItem objects from XML element.""" + all_extract_items = [] + all_extract_xml = parsed_response.findall(".//t:extract", namespaces=ns) + + for extract_xml in all_extract_xml: + extract_id = extract_xml.get("id", None) + priority = int(extract_xml.get("priority", 0)) + refresh_type = extract_xml.get("type", "") + + # Check for workbook or datasource + workbook_elem = extract_xml.find(".//t:workbook", namespaces=ns) + datasource_elem = extract_xml.find(".//t:datasource", namespaces=ns) + + workbook_id = workbook_elem.get("id", None) if workbook_elem is not None else None + datasource_id = datasource_elem.get("id", None) if datasource_elem is not None else None + + extract_item = cls(priority, refresh_type, workbook_id, datasource_id) + extract_item._id = extract_id + + all_extract_items.append(extract_item) + + return all_extract_items diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index 0083776bb..063897e41 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -146,8 +146,8 @@ def _set_connections(self, connections): def _set_permissions(self, permissions): self._permissions = permissions - def _set_data_quality_warnings(self, dqws): - self._data_quality_warnings = dqws + def _set_data_quality_warnings(self, dqw): + self._data_quality_warnings = dqw def _parse_common_elements(self, flow_xml, ns): if not isinstance(flow_xml, ET.Element): diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 0afd5582c..00f35e518 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -44,6 +44,11 @@ class GroupItem: login to a site. When the mode is onSync, a license is granted for group members each time the domain is synced. + Attributes + ---------- + user_count: Optional[int] + The number of users in the group. + Examples -------- >>> # Create a new group item @@ -65,6 +70,7 @@ def __init__(self, name=None, domain_name=None) -> None: self._users: Optional[Callable[..., "Pager"]] = None self.name: Optional[str] = name self.domain_name: Optional[str] = domain_name + self._user_count: Optional[int] = None def __repr__(self): return f"{self.__class__.__name__}({self.__dict__!r})" @@ -118,6 +124,10 @@ def users(self) -> "Pager": def _set_users(self, users: Callable[..., "Pager"]) -> None: self._users = users + @property + def user_count(self) -> Optional[int]: + return self._user_count + @classmethod def from_response(cls, resp, ns) -> list["GroupItem"]: all_group_items = list() @@ -127,6 +137,7 @@ def from_response(cls, resp, ns) -> list["GroupItem"]: name = group_xml.get("name", None) group_item = cls(name) group_item._id = group_xml.get("id", None) + group_item._user_count = int(count) if (count := group_xml.get("userCount", None)) else None # Domain name is returned in a domain element for some calls domain_elem = group_xml.find(".//t:domain", namespaces=ns) diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index d7cf891cc..52fd658c5 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -2,6 +2,13 @@ class IntervalItem: + """ + This class sets the frequency and start time of the scheduled item. This + class contains the classes for the hourly, daily, weekly, and monthly + intervals. This class mirrors the options you can set using the REST API and + the Tableau Server interface. + """ + class Frequency: Hourly = "Hourly" Daily = "Daily" @@ -26,6 +33,19 @@ class Day: class HourlyInterval: + """ + Runs scheduled item hourly. To set the hourly interval, you create an + instance of the HourlyInterval class and assign the following values: + start_time, end_time, and interval_value. To set the start_time and + end_time, assign the time value using this syntax: start_time=time(hour, minute) + and end_time=time(hour, minute). The hour is specified in 24 hour time. + The interval_value specifies how often the to run the task within the + start and end time. The options are expressed in hours. For example, + interval_value=.25 is every 15 minutes. The values are .25, .5, 1, 2, 4, 6, + 8, 12. Hourly schedules that run more frequently than every 60 minutes must + have start and end times that are on the hour. + """ + def __init__(self, start_time, end_time, interval_value): self.start_time = start_time self.end_time = end_time @@ -109,6 +129,12 @@ def _interval_type_pairs(self): class DailyInterval: + """ + Runs the scheduled item daily. To set the daily interval, you create an + instance of the DailyInterval and assign the start_time. The start time uses + the syntax start_time=time(hour, minute). + """ + def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -177,6 +203,15 @@ def _interval_type_pairs(self): class WeeklyInterval: + """ + Runs the scheduled item once a week. To set the weekly interval, you create + an instance of the WeeklyInterval and assign the start time and multiple + instances for the interval_value (days of week and start time). The start + time uses the syntax time(hour, minute). The interval_value is the day of + the week, expressed as a IntervalItem. For example + TSC.IntervalItem.Day.Monday for Monday. + """ + def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -214,6 +249,11 @@ def _interval_type_pairs(self): class MonthlyInterval: + """ + Runs the scheduled item once a month. To set the monthly interval, you + create an instance of the MonthlyInterval and assign the start time and day. + """ + def __init__(self, start_time, interval_value): self.start_time = start_time @@ -278,4 +318,4 @@ def interval(self, interval_values): self._interval = interval_values def _interval_type_pairs(self): - return [(IntervalItem.Occurrence.MonthDay, self.interval)] + return [(IntervalItem.Occurrence.MonthDay, str(day)) for day in self.interval] diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 6286275c5..d650eb846 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -82,6 +82,7 @@ class FinishCode: Success: int = 0 Failed: int = 1 Cancelled: int = 2 + Completed: int = 3 def __init__( self, diff --git a/tableauserverclient/models/location_item.py b/tableauserverclient/models/location_item.py new file mode 100644 index 000000000..fa7c2ff2c --- /dev/null +++ b/tableauserverclient/models/location_item.py @@ -0,0 +1,53 @@ +from typing import Optional +import xml.etree.ElementTree as ET + + +class LocationItem: + """ + Details of where an item is located, such as a personal space or project. + + Attributes + ---------- + id : str | None + The ID of the location. + + type : str | None + The type of location, such as PersonalSpace or Project. + + name : str | None + The name of the location. + """ + + class Type: + PersonalSpace = "PersonalSpace" + Project = "Project" + + def __init__(self): + self._id: Optional[str] = None + self._type: Optional[str] = None + self._name: Optional[str] = None + + def __repr__(self): + return f"{self.__class__.__name__}({self.__dict__!r})" + + @property + def id(self) -> Optional[str]: + return self._id + + @property + def type(self) -> Optional[str]: + return self._type + + @property + def name(self) -> Optional[str]: + return self._name + + @classmethod + def from_xml(cls, xml: ET.Element, ns: Optional[dict] = None) -> "LocationItem": + if ns is None: + ns = {} + location = cls() + location._id = xml.get("id", None) + location._type = xml.get("type", None) + location._name = xml.get("name", None) + return location diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 9be1196ba..1ab369ba7 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,11 +1,11 @@ -import logging import xml.etree.ElementTree as ET -from typing import Optional +from typing import Optional, overload from defusedxml.ElementTree import fromstring from tableauserverclient.models.exceptions import UnpopulatedPropertyError -from tableauserverclient.models.property_decorators import property_is_enum, property_not_empty +from tableauserverclient.models.property_decorators import property_is_enum +from tableauserverclient.models.user_item import UserItem class ProjectItem: @@ -39,12 +39,32 @@ class corresponds to the project resources you can access using the Tableau Attributes ---------- + datasource_count : int + The number of data sources in the project. + id : str The unique identifier for the project. + owner: Optional[UserItem] + The UserItem owner of the project. + owner_id : str The unique identifier for the UserItem owner of the project. + project_count : int + The number of projects in the project. + + top_level_project : bool + True if the project is a top-level project. + + view_count : int + The number of views in the project. + + workbook_count : int + The number of workbooks in the project. + + writeable : bool + True if the project is writeable. """ ERROR_MSG = "Project item must be populated with permissions first." @@ -75,6 +95,8 @@ def __init__( self.parent_id: Optional[str] = parent_id self._samples: Optional[bool] = samples self._owner_id: Optional[str] = None + self._top_level_project: Optional[bool] = None + self._writeable: Optional[bool] = None self._permissions = None self._default_workbook_permissions = None @@ -87,6 +109,13 @@ def __init__( self._default_database_permissions = None self._default_table_permissions = None + self._project_count: Optional[int] = None + self._workbook_count: Optional[int] = None + self._view_count: Optional[int] = None + self._datasource_count: Optional[int] = None + + self._owner: Optional[UserItem] = None + @property def content_permissions(self): return self._content_permissions @@ -176,25 +205,53 @@ def owner_id(self) -> Optional[str]: def owner_id(self, value: str) -> None: self._owner_id = value + @property + def top_level_project(self) -> Optional[bool]: + return self._top_level_project + + @property + def writeable(self) -> Optional[bool]: + return self._writeable + + @property + def project_count(self) -> Optional[int]: + return self._project_count + + @property + def workbook_count(self) -> Optional[int]: + return self._workbook_count + + @property + def view_count(self) -> Optional[int]: + return self._view_count + + @property + def datasource_count(self) -> Optional[int]: + return self._datasource_count + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + def is_default(self): return self.name.lower() == "default" - def _parse_common_tags(self, project_xml, ns): - if not isinstance(project_xml, ET.Element): - project_xml = fromstring(project_xml).find(".//t:project", namespaces=ns) - - if project_xml is not None: - ( - _, - name, - description, - content_permissions, - parent_id, - ) = self._parse_element(project_xml) - self._set_values(None, name, description, content_permissions, parent_id) - return self - - def _set_values(self, project_id, name, description, content_permissions, parent_id, owner_id): + def _set_values( + self, + project_id, + name, + description, + content_permissions, + parent_id, + owner_id, + top_level_project, + writeable, + project_count, + workbook_count, + view_count, + datasource_count, + owner, + ): if project_id is not None: self._id = project_id if name: @@ -207,6 +264,20 @@ def _set_values(self, project_id, name, description, content_permissions, parent self.parent_id = parent_id if owner_id: self._owner_id = owner_id + if project_count is not None: + self._project_count = project_count + if workbook_count is not None: + self._workbook_count = workbook_count + if view_count is not None: + self._view_count = view_count + if datasource_count is not None: + self._datasource_count = datasource_count + if top_level_project is not None: + self._top_level_project = top_level_project + if writeable is not None: + self._writeable = writeable + if owner is not None: + self._owner = owner def _set_permissions(self, permissions): self._permissions = permissions @@ -220,31 +291,71 @@ def _set_default_permissions(self, permissions, content_type): ) @classmethod - def from_response(cls, resp, ns) -> list["ProjectItem"]: + def from_response(cls, resp: bytes, ns: Optional[dict]) -> list["ProjectItem"]: all_project_items = list() parsed_response = fromstring(resp) all_project_xml = parsed_response.findall(".//t:project", namespaces=ns) for project_xml in all_project_xml: - project_item = cls.from_xml(project_xml) + project_item = cls.from_xml(project_xml, namespace=ns) all_project_items.append(project_item) return all_project_items @classmethod - def from_xml(cls, project_xml, namespace=None) -> "ProjectItem": + def from_xml(cls, project_xml: ET.Element, namespace: Optional[dict] = None) -> "ProjectItem": project_item = cls() - project_item._set_values(*cls._parse_element(project_xml)) + project_item._set_values(*cls._parse_element(project_xml, namespace)) return project_item @staticmethod - def _parse_element(project_xml): + def _parse_element(project_xml: ET.Element, namespace: Optional[dict]) -> tuple: id = project_xml.get("id", None) name = project_xml.get("name", None) description = project_xml.get("description", None) content_permissions = project_xml.get("contentPermissions", None) parent_id = project_xml.get("parentProjectId", None) + top_level_project = str_to_bool(project_xml.get("topLevelProject", None)) + writeable = str_to_bool(project_xml.get("writeable", None)) owner_id = None - for owner in project_xml: - owner_id = owner.get("id", None) + owner = None + if (owner_elem := project_xml.find(".//t:owner", namespaces=namespace)) is not None: + owner = UserItem.from_xml(owner_elem, namespace) + owner_id = owner_elem.get("id", None) + + project_count = None + workbook_count = None + view_count = None + datasource_count = None + if (count_elem := project_xml.find(".//t:contentsCounts", namespaces=namespace)) is not None: + project_count = int(count_elem.get("projectCount", 0)) + workbook_count = int(count_elem.get("workbookCount", 0)) + view_count = int(count_elem.get("viewCount", 0)) + datasource_count = int(count_elem.get("dataSourceCount", 0)) + + return ( + id, + name, + description, + content_permissions, + parent_id, + owner_id, + top_level_project, + writeable, + project_count, + workbook_count, + view_count, + datasource_count, + owner, + ) + + +@overload +def str_to_bool(value: str) -> bool: ... + + +@overload +def str_to_bool(value: None) -> None: ... + - return id, name, description, content_permissions, parent_id, owner_id +def str_to_bool(value): + return value.lower() == "true" if value is not None else None diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index e39042058..a2118e3d6 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -20,6 +20,63 @@ class ScheduleItem: + """ + Using the TSC library, you can schedule extract refresh or subscription + tasks on Tableau Server. You can also get and update information about the + scheduled tasks, or delete scheduled tasks. + + If you have the identifier of the job, you can use the TSC library to find + out the status of the asynchronous job. + + The schedule properties are defined in the ScheduleItem class. The class + corresponds to the properties for schedules you can access in Tableau + Server or by using the Tableau Server REST API. The Schedule methods are + based upon the endpoints for jobs in the REST API and operate on the JobItem + class. + + Parameters + ---------- + name : str + The name of the schedule. + + priority : int + The priority of the schedule. Lower values represent higher priority, + with 0 indicating the highest priority. + + schedule_type : str + The type of task schedule. See ScheduleItem.Type for the possible values. + + execution_order : str + Specifies how the scheduled tasks should run. The choices are Parallel + which uses all avaiable background processes for a scheduled task, or + Serial, which limits the schedule to one background process. + + interval_item : Interval + Specifies the frequency that the scheduled task should run. The + interval_item is an instance of the IntervalItem class. The + interval_item has properties for frequency (hourly, daily, weekly, + monthly), and what time and date the scheduled item runs. You set this + value by declaring an IntervalItem object that is one of the following: + HourlyInterval, DailyInterval, WeeklyInterval, or MonthlyInterval. + + Attributes + ---------- + created_at : datetime + The date and time the schedule was created. + + end_schedule_at : datetime + The date and time the schedule ends. + + id : str + The unique identifier for the schedule. + + next_run_at : datetime + The date and time the schedule is next run. + + state : str + The state of the schedule. See ScheduleItem.State for the possible values. + """ + class Type: Extract = "Extract" Flow = "Flow" diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index e4e146f9c..ab65b97b5 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -1188,6 +1188,34 @@ def _parse_element(site_xml, ns): ) +class SiteAuthConfiguration: + """ + Authentication configuration for a site. + """ + + def __init__(self): + self.auth_setting: Optional[str] = None + self.enabled: Optional[bool] = None + self.idp_configuration_id: Optional[str] = None + self.idp_configuration_name: Optional[str] = None + self.known_provider_alias: Optional[str] = None + + @classmethod + def from_response(cls, resp: bytes, ns: dict) -> list["SiteAuthConfiguration"]: + all_auth_configs = list() + parsed_response = fromstring(resp) + all_auth_xml = parsed_response.findall(".//t:siteAuthConfiguration", namespaces=ns) + for auth_xml in all_auth_xml: + auth_config = cls() + auth_config.auth_setting = auth_xml.get("authSetting", None) + auth_config.enabled = string_to_bool(auth_xml.get("enabled", "")) + auth_config.idp_configuration_id = auth_xml.get("idpConfigurationId", None) + auth_config.idp_configuration_name = auth_xml.get("idpConfigurationName", None) + auth_config.known_provider_alias = auth_xml.get("knownProviderAlias", None) + all_auth_configs.append(auth_config) + return all_auth_configs + + # Used to convert string represented boolean to a boolean type def string_to_bool(s: str) -> bool: return s.lower() == "true" diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index 0afdd4df3..541f84360 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -1,8 +1,12 @@ +from typing import Callable, Optional, TYPE_CHECKING from defusedxml.ElementTree import fromstring from .exceptions import UnpopulatedPropertyError from .property_decorators import property_not_empty, property_is_boolean +if TYPE_CHECKING: + from tableauserverclient.models import DQWItem + class TableItem: def __init__(self, name, description=None): @@ -40,7 +44,7 @@ def dqws(self): return self._data_quality_warnings() @property - def id(self): + def id(self) -> Optional[str]: return self._id @property @@ -100,8 +104,8 @@ def columns(self): def _set_columns(self, columns): self._columns = columns - def _set_data_quality_warnings(self, dqws): - self._data_quality_warnings = dqws + def _set_data_quality_warnings(self, dqw: Callable[[], list["DQWItem"]]) -> None: + self._data_quality_warnings = dqw def _set_values(self, table_values): if "id" in table_values: diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index 01ee3d3a9..e69d02a06 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -1,8 +1,10 @@ from typing import Union +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 tableauserverclient.models.metric_item import MetricItem @@ -25,7 +27,17 @@ class Resource: # resource types that have permissions, can be renamed, etc # todo: refactoring: should actually define TableauItem as an interface and let all these implement it -TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem, VirtualConnectionItem] +TableauItem = Union[ + DatasourceItem, + FlowItem, + MetricItem, + ProjectItem, + ViewItem, + WorkbookItem, + VirtualConnectionItem, + DatabaseItem, + TableItem, +] def plural_type(content_type: Union[Resource, str]) -> str: diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 365e44c1d..c995b4e07 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -7,6 +7,7 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.site_item import SiteAuthConfiguration from .exceptions import UnpopulatedPropertyError from .property_decorators import ( property_is_enum, @@ -37,6 +38,49 @@ class UserItem: auth_setting: str Required attribute for Tableau Cloud. How the user autenticates to the server. + + Attributes + ---------- + domain_name: Optional[str] + The name of the Active Directory domain ("local" if local authentication + is used). + + email: Optional[str] + The email address of the user. + + external_auth_user_id: Optional[str] + The unique identifier for the user in the external authentication system. + + id: Optional[str] + The unique identifier for the user. + + favorites: dict[str, list] + The favorites of the user. Must be populated with a call to + `populate_favorites()`. + + fullname: Optional[str] + The full name of the user. + + groups: Pager + The groups the user belongs to. Must be populated with a call to + `populate_groups()`. + + last_login: Optional[datetime] + The last time the user logged in. + + locale: Optional[str] + The locale of the user. + + language: Optional[str] + Language setting for the user. + + idp_configuration_id: Optional[str] + The ID of the identity provider configuration. + + workbooks: Pager + The workbooks owned by the user. Must be populated with a call to + `populate_workbooks()`. + """ tag_name: str = "user" @@ -94,6 +138,9 @@ def __init__( self.name: Optional[str] = name self.site_role: Optional[str] = site_role self.auth_setting: Optional[str] = auth_setting + self._locale: Optional[str] = None + self._language: Optional[str] = None + self._idp_configuration_id: Optional[str] = None return None @@ -184,6 +231,26 @@ def groups(self) -> "Pager": raise UnpopulatedPropertyError(error) return self._groups() + @property + def locale(self) -> Optional[str]: + return self._locale + + @property + def language(self) -> Optional[str]: + return self._language + + @property + def idp_configuration_id(self) -> Optional[str]: + """ + IDP configuration id for the user. This is only available on Tableau + Cloud, 3.24 or later + """ + return self._idp_configuration_id + + @idp_configuration_id.setter + def idp_configuration_id(self, value: str) -> None: + self._idp_configuration_id = value + def _set_workbooks(self, workbooks) -> None: self._workbooks = workbooks @@ -204,8 +271,11 @@ def _parse_common_tags(self, user_xml, ns) -> "UserItem": email, auth_setting, _, + _, + _, + _, ) = self._parse_element(user_xml, ns) - self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None) + self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None, None, None, None) return self def _set_values( @@ -219,6 +289,9 @@ def _set_values( email, auth_setting, domain_name, + locale, + language, + idp_configuration_id, ): if id is not None: self._id = id @@ -238,6 +311,12 @@ def _set_values( self._auth_setting = auth_setting if domain_name: self._domain_name = domain_name + if locale: + self._locale = locale + if language: + self._language = language + if idp_configuration_id: + self._idp_configuration_id = idp_configuration_id @classmethod def from_response(cls, resp, ns) -> list["UserItem"]: @@ -249,6 +328,12 @@ def from_response_as_owner(cls, resp, ns) -> list["UserItem"]: element_name = ".//t:owner" return cls._parse_xml(element_name, resp, ns) + @classmethod + def from_xml(cls, xml: ET.Element, ns: Optional[dict] = None) -> "UserItem": + item = cls() + item._set_values(*cls._parse_element(xml, ns)) + return item + @classmethod def _parse_xml(cls, element_name, resp, ns): all_user_items = [] @@ -265,6 +350,9 @@ def _parse_xml(cls, element_name, resp, ns): email, auth_setting, domain_name, + locale, + language, + idp_configuration_id, ) = cls._parse_element(user_xml, ns) user_item = cls(name, site_role) user_item._set_values( @@ -277,6 +365,9 @@ def _parse_xml(cls, element_name, resp, ns): email, auth_setting, domain_name, + locale, + language, + idp_configuration_id, ) all_user_items.append(user_item) return all_user_items @@ -295,6 +386,9 @@ def _parse_element(user_xml, ns): fullname = user_xml.get("fullName", None) email = user_xml.get("email", None) auth_setting = user_xml.get("authSetting", None) + locale = user_xml.get("locale", None) + language = user_xml.get("language", None) + idp_configuration_id = user_xml.get("idpConfigurationId", None) domain_name = None domain_elem = user_xml.find(".//t:domain", namespaces=ns) @@ -311,6 +405,9 @@ def _parse_element(user_xml, ns): email, auth_setting, domain_name, + locale, + language, + idp_configuration_id, ) class CSVImport: @@ -361,6 +458,9 @@ def create_user_from_line(line: str): values[UserItem.CSVImport.ColumnType.EMAIL], values[UserItem.CSVImport.ColumnType.AUTH], None, + None, + None, + None, ) return user diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index 88cec7328..dc8eda9c8 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,15 +1,21 @@ import copy from datetime import datetime from requests import Response -from typing import Callable, Optional +from typing import TYPE_CHECKING, Callable, Optional, overload from collections.abc import Iterator from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.location_item import LocationItem from tableauserverclient.models.permissions_item import PermissionsRule +from tableauserverclient.models.project_item import ProjectItem from tableauserverclient.models.tag_item import TagItem +from tableauserverclient.models.user_item import UserItem + +if TYPE_CHECKING: + from tableauserverclient.models.workbook_item import WorkbookItem class ViewItem: @@ -34,9 +40,16 @@ class ViewItem: The image of the view. You must first call the `views.populate_image` method to access the image. + location: Optional[LocationItem], default None + The location of the view. The location can be a personal space or a + project. + name: Optional[str], default None The name of the view. + owner: Optional[UserItem], default None + The owner of the view. + owner_id: Optional[str], default None The ID for the owner of the view. @@ -48,6 +61,9 @@ class ViewItem: The preview image of the view. You must first call the `views.populate_preview_image` method to access the preview image. + project: Optional[ProjectItem], default None + The project that contains the view. + project_id: Optional[str], default None The ID for the project that contains the view. @@ -60,9 +76,11 @@ class ViewItem: updated_at: Optional[datetime], default None The date and time when the view was last updated. + workbook: Optional[WorkbookItem], default None + The workbook that contains the view. + workbook_id: Optional[str], default None The ID for the workbook that contains the view. - """ def __init__(self) -> None: @@ -84,11 +102,18 @@ def __init__(self) -> None: self._workbook_id: Optional[str] = None self._permissions: Optional[Callable[[], list[PermissionsRule]]] = None self.tags: set[str] = set() + self._favorites_total: Optional[int] = None + self._view_url_name: Optional[str] = None self._data_acceleration_config = { "acceleration_enabled": None, "acceleration_status": None, } + self._owner: Optional[UserItem] = None + self._project: Optional[ProjectItem] = None + self._workbook: Optional["WorkbookItem"] = None + self._location: Optional[LocationItem] = None + def __str__(self): return "".format( self._id, self.name, self.content_url, self.project_id @@ -190,6 +215,14 @@ def updated_at(self) -> Optional[datetime]: def workbook_id(self) -> Optional[str]: return self._workbook_id + @property + def view_url_name(self) -> Optional[str]: + return self._view_url_name + + @property + def favorites_total(self) -> Optional[int]: + return self._favorites_total + @property def data_acceleration_config(self): return self._data_acceleration_config @@ -198,6 +231,22 @@ def data_acceleration_config(self): def data_acceleration_config(self, value): self._data_acceleration_config = value + @property + def project(self) -> Optional["ProjectItem"]: + return self._project + + @property + def workbook(self) -> Optional["WorkbookItem"]: + return self._workbook + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + + @property + def location(self) -> Optional[LocationItem]: + return self._location + @property def permissions(self) -> list[PermissionsRule]: if self._permissions is None: @@ -228,7 +277,7 @@ def from_xml(cls, view_xml, ns, workbook_id="") -> "ViewItem": workbook_elem = view_xml.find(".//t:workbook", namespaces=ns) owner_elem = view_xml.find(".//t:owner", namespaces=ns) project_elem = view_xml.find(".//t:project", namespaces=ns) - tags_elem = view_xml.find(".//t:tags", namespaces=ns) + tags_elem = view_xml.find("./t:tags", namespaces=ns) data_acceleration_config_elem = view_xml.find(".//t:dataAccelerationConfig", namespaces=ns) view_item._created_at = parse_datetime(view_xml.get("createdAt", None)) view_item._updated_at = parse_datetime(view_xml.get("updatedAt", None)) @@ -236,22 +285,35 @@ def from_xml(cls, view_xml, ns, workbook_id="") -> "ViewItem": view_item._name = view_xml.get("name", None) view_item._content_url = view_xml.get("contentUrl", None) view_item._sheet_type = view_xml.get("sheetType", None) + view_item._favorites_total = string_to_int(view_xml.get("favoritesTotal", None)) + view_item._view_url_name = view_xml.get("viewUrlName", None) if usage_elem is not None: total_view = usage_elem.get("totalViewCount", None) if total_view: view_item._total_views = int(total_view) if owner_elem is not None: + user = UserItem.from_xml(owner_elem, ns) + view_item._owner = user view_item._owner_id = owner_elem.get("id", None) if project_elem is not None: - view_item._project_id = project_elem.get("id", None) + project_item = ProjectItem.from_xml(project_elem, ns) + view_item._project = project_item + view_item._project_id = project_item.id if workbook_id: view_item._workbook_id = workbook_id elif workbook_elem is not None: - view_item._workbook_id = workbook_elem.get("id", None) + from tableauserverclient.models.workbook_item import WorkbookItem + + workbook_item = WorkbookItem.from_xml(workbook_elem, ns) + view_item._workbook = workbook_item + view_item._workbook_id = workbook_item.id if tags_elem is not None: tags = TagItem.from_xml_element(tags_elem, ns) view_item.tags = tags view_item._initial_tags = copy.copy(tags) + if (location_elem := view_xml.find(".//t:location", namespaces=ns)) is not None: + location = LocationItem.from_xml(location_elem, ns) + view_item._location = location if data_acceleration_config_elem is not None: data_acceleration_config = parse_data_acceleration_config(data_acceleration_config_elem) view_item.data_acceleration_config = data_acceleration_config @@ -274,3 +336,15 @@ def parse_data_acceleration_config(data_acceleration_elem): def string_to_bool(s: str) -> bool: return s.lower() == "true" + + +@overload +def string_to_int(s: None) -> None: ... + + +@overload +def string_to_int(s: str) -> int: ... + + +def string_to_int(s): + return int(s) if s is not None else None diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 32ab413a4..a3ede65d6 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -2,11 +2,14 @@ import datetime import uuid import xml.etree.ElementTree as ET -from typing import Callable, Optional +from typing import Callable, Optional, overload from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.location_item import LocationItem +from tableauserverclient.models.project_item import ProjectItem +from tableauserverclient.models.user_item import UserItem from .connection_item import ConnectionItem from .exceptions import UnpopulatedPropertyError from .permissions_item import PermissionsRule @@ -51,13 +54,31 @@ class as arguments. The workbook item specifies the project. created_at : Optional[datetime.datetime] The date and time the workbook was created. + default_view_id : Optional[str] + The identifier for the default view of the workbook. + description : Optional[str] User-defined description of the workbook. + encrypt_extracts : Optional[bool] + Indicates whether extracts are encrypted. + + has_extracts : Optional[bool] + Indicates whether the workbook has extracts. + id : Optional[str] The identifier for the workbook. You need this value to query a specific workbook or to delete a workbook with the get_by_id and delete methods. + last_published_at : Optional[datetime.datetime] + The date and time the workbook was last published. + + location : Optional[LocationItem] + The location of the workbook, such as a personal space or project. + + owner : Optional[UserItem] + The owner of the workbook. + owner_id : Optional[str] The identifier for the owner (UserItem) of the workbook. @@ -65,6 +86,9 @@ class as arguments. The workbook item specifies the project. The thumbnail image for the view. You must first call the workbooks.populate_preview_image method to access this data. + project: Optional[ProjectItem] + The project that contains the workbook. + project_name : Optional[str] The name of the project that contains the workbook. @@ -139,6 +163,15 @@ def __init__( self._permissions = None self.thumbnails_user_id = thumbnails_user_id self.thumbnails_group_id = thumbnails_group_id + self._sheet_count: Optional[int] = None + self._has_extracts: Optional[bool] = None + self._project: Optional[ProjectItem] = None + self._owner: Optional[UserItem] = None + self._location: Optional[LocationItem] = None + self._encrypt_extracts: Optional[bool] = None + self._default_view_id: Optional[str] = None + self._share_description: Optional[str] = None + self._last_published_at: Optional[datetime.datetime] = None return None @@ -234,6 +267,14 @@ def show_tabs(self, value: bool): def size(self): return self._size + @property + def sheet_count(self) -> Optional[int]: + return self._sheet_count + + @property + def has_extracts(self) -> Optional[bool]: + return self._has_extracts + @property def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at @@ -300,6 +341,34 @@ def thumbnails_group_id(self) -> Optional[str]: def thumbnails_group_id(self, value: str): self._thumbnails_group_id = value + @property + def project(self) -> Optional[ProjectItem]: + return self._project + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + + @property + def location(self) -> Optional[LocationItem]: + return self._location + + @property + def encrypt_extracts(self) -> Optional[bool]: + return self._encrypt_extracts + + @property + def default_view_id(self) -> Optional[str]: + return self._default_view_id + + @property + def share_description(self) -> Optional[str]: + return self._share_description + + @property + def last_published_at(self) -> Optional[datetime.datetime]: + return self._last_published_at + def _set_connections(self, connections): self._connections = connections @@ -342,6 +411,15 @@ def _parse_common_tags(self, workbook_xml, ns): views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ) = self._parse_element(workbook_xml, ns) self._set_values( @@ -361,6 +439,15 @@ def _parse_common_tags(self, workbook_xml, ns): views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ) return self @@ -383,6 +470,15 @@ def _set_values( views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ): if id is not None: self._id = id @@ -417,6 +513,24 @@ def _set_values( self.data_acceleration_config = data_acceleration_config if data_freshness_policy is not None: self.data_freshness_policy = data_freshness_policy + if sheet_count is not None: + self._sheet_count = sheet_count + if has_extracts is not None: + self._has_extracts = has_extracts + if project: + self._project = project + if owner: + self._owner = owner + if location: + self._location = location + if encrypt_extracts is not None: + self._encrypt_extracts = encrypt_extracts + if default_view_id is not None: + self._default_view_id = default_view_id + if share_description is not None: + self._share_description = share_description + if last_published_at is not None: + self._last_published_at = last_published_at @classmethod def from_response(cls, resp: str, ns: dict[str, str]) -> list["WorkbookItem"]: @@ -443,6 +557,12 @@ def _parse_element(workbook_xml, ns): created_at = parse_datetime(workbook_xml.get("createdAt", None)) description = workbook_xml.get("description", None) updated_at = parse_datetime(workbook_xml.get("updatedAt", None)) + sheet_count = string_to_int(workbook_xml.get("sheetCount", None)) + has_extracts = string_to_bool(workbook_xml.get("hasExtracts", "")) + encrypt_extracts = string_to_bool(e) if (e := workbook_xml.get("encryptExtracts", None)) is not None else None + default_view_id = workbook_xml.get("defaultViewId", None) + share_description = workbook_xml.get("shareDescription", None) + last_published_at = parse_datetime(workbook_xml.get("lastPublishedAt", None)) size = workbook_xml.get("size", None) if size: @@ -452,14 +572,18 @@ def _parse_element(workbook_xml, ns): project_id = None project_name = None + project = None project_tag = workbook_xml.find(".//t:project", namespaces=ns) if project_tag is not None: + project = ProjectItem.from_xml(project_tag, ns) project_id = project_tag.get("id", None) project_name = project_tag.get("name", None) owner_id = None + owner = None owner_tag = workbook_xml.find(".//t:owner", namespaces=ns) if owner_tag is not None: + owner = UserItem.from_xml(owner_tag, ns) owner_id = owner_tag.get("id", None) tags = None @@ -473,6 +597,11 @@ def _parse_element(workbook_xml, ns): if views_elem is not None: views = ViewItem.from_xml_element(views_elem, ns) + location = None + location_elem = workbook_xml.find(".//t:location", namespaces=ns) + if location_elem is not None: + location = LocationItem.from_xml(location_elem, ns) + data_acceleration_config = { "acceleration_enabled": None, "accelerate_now": None, @@ -505,6 +634,15 @@ def _parse_element(workbook_xml, ns): views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ) @@ -535,3 +673,15 @@ def parse_data_acceleration_config(data_acceleration_elem): # Used to convert string represented boolean to a boolean type def string_to_bool(s: str) -> bool: return s.lower() == "true" + + +@overload +def string_to_int(s: None) -> None: ... + + +@overload +def string_to_int(s: str) -> int: ... + + +def string_to_int(s): + return int(s) if s is not None else None diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 87cc9460b..55288fdc9 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -5,6 +5,7 @@ ExcelRequestOptions, ImageRequestOptions, PDFRequestOptions, + PPTXRequestOptions, RequestOptions, ) from tableauserverclient.server.filter import Filter @@ -52,6 +53,7 @@ "ExcelRequestOptions", "ImageRequestOptions", "PDFRequestOptions", + "PPTXRequestOptions", "RequestOptions", "Filter", "Sort", diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index c0e106eb2..dc88ceaa5 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -1,7 +1,8 @@ import logging -from typing import Union +from typing import TYPE_CHECKING, Optional, Union from collections.abc import Iterable +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.endpoint.default_permissions_endpoint import _DefaultPermissionsEndpoint from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint from tableauserverclient.server.endpoint.endpoint import api, Endpoint @@ -13,6 +14,10 @@ from tableauserverclient.helpers.logging import logger +if TYPE_CHECKING: + from tableauserverclient.models.dqw_item import DQWItem + from tableauserverclient.server.request_options import RequestOptions + class Databases(Endpoint, TaggingMixin): def __init__(self, parent_srv): @@ -23,11 +28,29 @@ def __init__(self, parent_srv): self._data_quality_warnings = _DataQualityWarningEndpoint(parent_srv, Resource.Database) @property - def baseurl(self): + def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/databases" @api(version="3.5") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[DatabaseItem], PaginationItem]: + """ + Get information about all databases on the site. Endpoint is paginated, + and will return a default of 100 items per page. Use the `req_options` + parameter to customize the request. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_databases + + Parameters + ---------- + req_options : RequestOptions, optional + Options to customize the request. If not provided, defaults to None. + + Returns + ------- + tuple[list[DatabaseItem], PaginationItem] + A tuple containing a list of DatabaseItem objects and a + PaginationItem object. + """ logger.info("Querying all databases on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -37,7 +60,27 @@ def get(self, req_options=None): # Get 1 database @api(version="3.5") - def get_by_id(self, database_id): + def get_by_id(self, database_id: str) -> DatabaseItem: + """ + Get information about a single database asset on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_database + + Parameters + ---------- + database_id : str + The ID of the database to retrieve. + + Returns + ------- + DatabaseItem + A DatabaseItem object representing the database. + + Raises + ------ + ValueError + If the database ID is undefined. + """ if not database_id: error = "database ID undefined." raise ValueError(error) @@ -47,7 +90,24 @@ def get_by_id(self, database_id): return DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="3.5") - def delete(self, database_id): + def delete(self, database_id: str) -> None: + """ + Deletes a single database asset from the server. + + Parameters + ---------- + database_id : str + The ID of the database to delete. + + Returns + ------- + None + + Raises + ------ + ValueError + If the database ID is undefined. + """ if not database_id: error = "Database ID undefined." raise ValueError(error) @@ -56,7 +116,28 @@ def delete(self, database_id): logger.info(f"Deleted single database (ID: {database_id})") @api(version="3.5") - def update(self, database_item): + def update(self, database_item: DatabaseItem) -> DatabaseItem: + """ + Update the database description, certify the database, set permissions, + or assign a User as the database contact. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_database + + Parameters + ---------- + database_item : DatabaseItem + The DatabaseItem object to update. + + Returns + ------- + DatabaseItem + The updated DatabaseItem object. + + Raises + ------ + MissingRequiredFieldError + If the database item is missing an ID. + """ if not database_item.id: error = "Database item missing ID." raise MissingRequiredFieldError(error) @@ -88,43 +169,45 @@ def _get_tables_for_database(self, database_item): return tables @api(version="3.5") - def populate_permissions(self, item): + def populate_permissions(self, item: DatabaseItem) -> None: self._permissions.populate(item) @api(version="3.5") - def update_permissions(self, item, rules): + def update_permissions(self, item: DatabaseItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._permissions.update(item, rules) @api(version="3.5") - def delete_permission(self, item, rules): + def delete_permission(self, item: DatabaseItem, rules: list[PermissionsRule]) -> None: self._permissions.delete(item, rules) @api(version="3.5") - def populate_table_default_permissions(self, item): + def populate_table_default_permissions(self, item: DatabaseItem): self._default_permissions.populate_default_permissions(item, Resource.Table) @api(version="3.5") - def update_table_default_permissions(self, item): - return self._default_permissions.update_default_permissions(item, Resource.Table) + def update_table_default_permissions( + self, item: DatabaseItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: + return self._default_permissions.update_default_permissions(item, rules, Resource.Table) @api(version="3.5") - def delete_table_default_permissions(self, item): - self._default_permissions.delete_default_permission(item, Resource.Table) + def delete_table_default_permissions(self, rule: PermissionsRule, item: DatabaseItem) -> None: + self._default_permissions.delete_default_permission(item, rule, Resource.Table) @api(version="3.5") - def populate_dqw(self, item): + def populate_dqw(self, item: DatabaseItem) -> None: self._data_quality_warnings.populate(item) @api(version="3.5") - def update_dqw(self, item, warning): + def update_dqw(self, item: DatabaseItem, warning: "DQWItem") -> list["DQWItem"]: return self._data_quality_warnings.update(item, warning) @api(version="3.5") - def add_dqw(self, item, warning): + def add_dqw(self, item: DatabaseItem, warning: "DQWItem") -> list["DQWItem"]: return self._data_quality_warnings.add(item, warning) @api(version="3.5") - def delete_dqw(self, item): + def delete_dqw(self, item: DatabaseItem) -> None: self._data_quality_warnings.clear(item) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 1f00af570..168446974 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -6,10 +6,11 @@ from contextlib import closing from pathlib import Path -from typing import Optional, TYPE_CHECKING, Union +from typing import Literal, Optional, TYPE_CHECKING, Union, overload from collections.abc import Iterable, Mapping, Sequence from tableauserverclient.helpers.headers import fix_filename +from tableauserverclient.models.dqw_item import DQWItem from tableauserverclient.server.query import QuerySet if TYPE_CHECKING: @@ -71,6 +72,28 @@ def baseurl(self) -> str: # Get all datasources @api(version="2.0") def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[DatasourceItem], PaginationItem]: + """ + Returns a list of published data sources on the specified site, with + optional parameters for specifying the paging of large results. To get + a list of data sources embedded in a workbook, use the Query Workbook + Connections method. + + Endpoint is paginated, and will return one page per call. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#query_data_sources + + Parameters + ---------- + req_options : Optional[RequestOptions] + Optional parameters for the request, such as filters, sorting, page + size, and page number. + + Returns + ------- + tuple[list[DatasourceItem], PaginationItem] + A tuple containing the list of datasource items and pagination + information. + """ logger.info("Querying all datasources on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -81,6 +104,21 @@ def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[Dataso # Get 1 datasource by id @api(version="2.0") def get_by_id(self, datasource_id: str) -> DatasourceItem: + """ + Returns information about a specific data source. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#query_data_source + + Parameters + ---------- + datasource_id : str + The unique ID of the datasource to retrieve. + + Returns + ------- + DatasourceItem + An object containing information about the datasource. + """ if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) @@ -92,6 +130,20 @@ def get_by_id(self, datasource_id: str) -> DatasourceItem: # Populate datasource item's connections @api(version="2.0") def populate_connections(self, datasource_item: DatasourceItem) -> None: + """ + Retrieve connection information for the specificed datasource item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#query_data_source_connections + + Parameters + ---------- + datasource_item : DatasourceItem + The datasource item to retrieve connections for. + + Returns + ------- + None + """ if not datasource_item.id: error = "Datasource item missing ID. Datasource must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -116,6 +168,22 @@ def _get_datasource_connections( # Delete 1 datasource by id @api(version="2.0") def delete(self, datasource_id: str) -> None: + """ + Deletes the specified data source from a site. When a data source is + deleted, its associated data connection is also deleted. Workbooks that + use the data source are not deleted, but they will no longer work + properly. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#delete_data_source + + Parameters + ---------- + datasource_id : str + + Returns + ------- + None + """ if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) @@ -133,6 +201,29 @@ def download( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: + """ + Downloads the specified data source from a site. The data source is + downloaded as a .tdsx file. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#download_data_source + + Parameters + ---------- + datasource_id : str + The unique ID of the datasource to download. + + filepath : Optional[PathOrFileW] + The file path to save the downloaded datasource to. If not + specified, the file will be saved to the current working directory. + + include_extract : bool, default True + If True, the extract is included in the download. If False, the + extract is not included. + + Returns + ------- + filepath : PathOrFileW + """ return self.download_revision( datasource_id, None, @@ -143,6 +234,28 @@ def download( # Update datasource @api(version="2.0") def update(self, datasource_item: DatasourceItem) -> DatasourceItem: + """ + Updates the owner, project or certification status of the specified + data source. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#update_data_source + + Parameters + ---------- + datasource_item : DatasourceItem + The datasource item to update. + + Returns + ------- + DatasourceItem + An object containing information about the updated datasource. + + Raises + ------ + MissingRequiredFieldError + If the datasource item is missing an ID. + """ + if not datasource_item.id: error = "Datasource item missing ID. Datasource must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -171,6 +284,26 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: def update_connection( self, datasource_item: DatasourceItem, connection_item: ConnectionItem ) -> Optional[ConnectionItem]: + """ + Updates the server address, port, username, or password for the + specified data source connection. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#update_data_source_connection + + Parameters + ---------- + datasource_item : DatasourceItem + The datasource item to update. + + connection_item : ConnectionItem + The connection item to update. + + Returns + ------- + Optional[ConnectionItem] + An object containing information about the updated connection. + """ + url = f"{self.baseurl}/{datasource_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) @@ -188,15 +321,51 @@ def update_connection( @api(version="2.8") def refresh(self, datasource_item: DatasourceItem, incremental: bool = False) -> JobItem: + """ + Refreshes the extract of an existing workbook. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#run_extract_refresh_task + + Parameters + ---------- + workbook_item : WorkbookItem | str + The workbook item or workbook ID. + incremental: bool + Whether to do a full refresh or incremental refresh of the extract data + + Returns + ------- + JobItem + The job item. + """ id_ = getattr(datasource_item, "id", datasource_item) url = f"{self.baseurl}/{id_}/refresh" - refresh_req = RequestFactory.Task.refresh_req(incremental) + refresh_req = RequestFactory.Task.refresh_req(incremental, self.parent_srv) server_response = self.post_request(url, refresh_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job @api(version="3.5") def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) -> JobItem: + """ + Create an extract for a data source in a site. Optionally, encrypt the + extract if the site and workbooks using it are configured to allow it. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#create_extract_for_datasource + + Parameters + ---------- + datasource_item : DatasourceItem | str + The datasource item or datasource ID. + + encrypt : bool, default False + Whether to encrypt the extract. + + Returns + ------- + JobItem + The job item. + """ id_ = getattr(datasource_item, "id", datasource_item) url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}" empty_req = RequestFactory.Empty.empty_req() @@ -206,11 +375,49 @@ def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) @api(version="3.5") def delete_extract(self, datasource_item: DatasourceItem) -> None: + """ + Delete the extract of a data source in a site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#delete_extract_from_datasource + + Parameters + ---------- + datasource_item : DatasourceItem | str + The datasource item or datasource ID. + + Returns + ------- + None + """ id_ = getattr(datasource_item, "id", datasource_item) url = f"{self.baseurl}/{id_}/deleteExtract" empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) + @overload + def publish( + self, + datasource_item: DatasourceItem, + file: PathOrFileR, + mode: str, + connection_credentials: Optional[ConnectionCredentials] = None, + connections: Optional[Sequence[ConnectionItem]] = None, + as_job: Literal[False] = False, + ) -> DatasourceItem: + pass + + @overload + def publish( + self, + datasource_item: DatasourceItem, + file: PathOrFileR, + mode: str, + connection_credentials: Optional[ConnectionCredentials] = None, + connections: Optional[Sequence[ConnectionItem]] = None, + as_job: Literal[True] = True, + ) -> JobItem: + pass + # Publish datasource @api(version="2.0") @parameter_added_in(connections="2.8") @@ -224,6 +431,50 @@ def publish( connections: Optional[Sequence[ConnectionItem]] = None, as_job: bool = False, ) -> Union[DatasourceItem, JobItem]: + """ + Publishes a data source to a server, or appends data to an existing + data source. + + This method checks the size of the data source and automatically + determines whether the publish the data source in multiple parts or in + one operation. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#publish_data_source + + Parameters + ---------- + datasource_item : DatasourceItem + The datasource item to publish. The fields for name and project_id + are required. + + file : PathOrFileR + The file path or file object to publish. + + mode : str + Specifies whether you are publishing a new datasource (CreateNew), + overwriting an existing datasource (Overwrite), or add to an + existing datasource (Append). You can also use the publish mode + attributes, for example: TSC.Server.PublishMode.Overwrite. + + connection_credentials : Optional[ConnectionCredentials] + The connection credentials to use when publishing the datasource. + Mutually exclusive with the connections parameter. + + connections : Optional[Sequence[ConnectionItem]] + The connections to use when publishing the datasource. Mutually + exclusive with the connection_credentials parameter. + + as_job : bool, default False + If True, the publish operation is asynchronous and returns a job + item. If False, the publish operation is synchronous and returns a + datasource item. + + Returns + ------- + Union[DatasourceItem, JobItem] + The datasource item or job item. + + """ if isinstance(file, (os.PathLike, str)): if not os.path.isfile(file): error = "File path does not lead to an existing file." @@ -328,6 +579,51 @@ def update_hyper_data( actions: Sequence[Mapping], payload: Optional[FilePath] = None, ) -> JobItem: + """ + Incrementally updates data (insert, update, upsert, replace and delete) + in a published data source from a live-to-Hyper connection, where the + data source has multiple connections. + + A live-to-Hyper connection has a Hyper or Parquet formatted + file/database as the origin of its data. + + For all connections to Parquet files, and for any data sources with a + single connection generally, you can use the Update Data in Hyper Data + Source method without specifying the connection-id. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#update_data_in_hyper_connection + + Parameters + ---------- + datasource_or_connection_item : Union[DatasourceItem, ConnectionItem, str] + The datasource item, connection item, or datasource ID. Either a + DataSourceItem or a ConnectionItem. If the datasource only contains + a single connection, the DataSourceItem is sufficient to identify + which data should be updated. Otherwise, for datasources with + multiple connections, a ConnectionItem must be provided. + + request_id : str + User supplied arbitrary string to identify the request. A request + identified with the same key will only be executed once, even if + additional requests using the key are made, for instance, due to + retries when facing network issues. + + actions : Sequence[Mapping] + A list of actions (insert, update, delete, ...) specifying how to + modify the data within the published datasource. For more + information on the actions, see: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_how_to_update_data_to_hyper.htm#action-batch-descriptions + + payload : Optional[FilePath] + A Hyper file containing tuples to be inserted/deleted/updated or + other payload data used by the actions. Hyper files can be created + using the Tableau Hyper API or pantab. + + Returns + ------- + JobItem + The job running on the server. + + """ if isinstance(datasource_or_connection_item, DatasourceItem): datasource_id = datasource_or_connection_item.id url = f"{self.baseurl}/{datasource_id}/data" @@ -356,35 +652,179 @@ def update_hyper_data( @api(version="2.0") def populate_permissions(self, item: DatasourceItem) -> None: + """ + Populates the permissions on the specified datasource item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_data_source_permissions + + Parameters + ---------- + item : DatasourceItem + The datasource item to populate permissions for. + + Returns + ------- + None + """ self._permissions.populate(item) @api(version="2.0") def update_permissions(self, item: DatasourceItem, permission_item: list["PermissionsRule"]) -> None: + """ + Updates the permissions on the specified datasource item. This method + overwrites all existing permissions. Any permissions not included in + the list will be removed. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_permissions_for_content + + Parameters + ---------- + item : DatasourceItem + The datasource item to update permissions for. + + permission_item : list[PermissionsRule] + The permissions to apply to the datasource item. + + Returns + ------- + None + """ self._permissions.update(item, permission_item) @api(version="2.0") def delete_permission(self, item: DatasourceItem, capability_item: "PermissionsRule") -> None: + """ + Deletes a single permission rule from the specified datasource item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_data_source_permissionDatasourceItem + + Parameters + ---------- + item : DatasourceItem + The datasource item to delete permissions from. + + capability_item : PermissionsRule + The permission rule to delete. + + Returns + ------- + None + """ self._permissions.delete(item, capability_item) @api(version="3.5") - def populate_dqw(self, item): + def populate_dqw(self, item) -> None: + """ + Get information about the data quality warning for the database, table, + column, published data source, flow, virtual connection, or virtual + connection table. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_dqws + + Parameters + ---------- + item : DatasourceItem + The datasource item to populate data quality warnings for. + + Returns + ------- + None + """ self._data_quality_warnings.populate(item) @api(version="3.5") - def update_dqw(self, item, warning): + def update_dqw(self, item: DatasourceItem, warning: "DQWItem") -> list["DQWItem"]: + """ + Update the warning type, status, and message of a data quality warning. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_dqw + + Parameters + ---------- + item : DatasourceItem + The datasource item to update data quality warnings for. + + warning : DQWItem + The data quality warning to update. + + Returns + ------- + DQWItem + The updated data quality warning. + """ return self._data_quality_warnings.update(item, warning) @api(version="3.5") - def add_dqw(self, item, warning): + def add_dqw(self, item: DatasourceItem, warning: "DQWItem") -> list["DQWItem"]: + """ + Add a data quality warning to a datasource. + + The Add Data Quality Warning method adds a data quality warning to an + asset. (An automatically-generated monitoring warning does not count + towards this limit.) In Tableau Cloud February 2024 and Tableau Server + 2024.2 and earlier, adding a data quality warning to an asset that + already has one causes an error. + + This method is available if your Tableau Cloud site or Tableau Server is licensed with Data Management. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#add_dqw + + Parameters + ---------- + item: DatasourceItem + The datasource item to add data quality warnings to. + + warning: DQWItem + The data quality warning to add. + + Returns + ------- + DQWItem + The added data quality warning. + + """ return self._data_quality_warnings.add(item, warning) @api(version="3.5") - def delete_dqw(self, item): + def delete_dqw(self, item: DatasourceItem) -> None: + """ + Delete a data quality warnings from an asset. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#delete_dqws + + Parameters + ---------- + item: DatasourceItem + The datasource item to delete data quality warnings from. + + Returns + ------- + None + """ self._data_quality_warnings.clear(item) # Populate datasource item's revisions @api(version="2.3") def populate_revisions(self, datasource_item: DatasourceItem) -> None: + """ + Retrieve revision information for the specified datasource item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#get_data_source_revisions + + Parameters + ---------- + datasource_item : DatasourceItem + The datasource item to retrieve revisions for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the datasource item is missing an ID. + """ if not datasource_item.id: error = "Datasource item missing ID. Datasource must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -412,6 +852,35 @@ def download_revision( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: + """ + Downloads a specific version of a data source prior to the current one + in .tdsx format. To download the current version of a data source set + the revision number to None. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#download_data_source_revision + + Parameters + ---------- + datasource_id : str + The unique ID of the datasource to download. + + revision_number : Optional[str] + The revision number of the data source to download. To determine + what versions are available, call the `populate_revisions` method. + Pass None to download the current version. + + filepath : Optional[PathOrFileW] + The file path to save the downloaded datasource to. If not + specified, the file will be saved to the current working directory. + + include_extract : bool, default True + If True, the extract is included in the download. If False, the + extract is not included. + + Returns + ------- + filepath : PathOrFileW + """ if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) @@ -445,6 +914,28 @@ def download_revision( @api(version="2.3") def delete_revision(self, datasource_id: str, revision_number: str) -> None: + """ + Removes a specific version of a data source from the specified site. + + The content is removed, and the specified revision can no longer be + downloaded using Download Data Source Revision. If you call Get Data + Source Revisions, the revision that's been removed is listed with the + attribute is_deleted=True. + + REST API:https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#remove_data_source_revision + + Parameters + ---------- + datasource_id : str + The unique ID of the datasource to delete. + + revision_number : str + The revision number of the data source to delete. + + Returns + ------- + None + """ if datasource_id is None or revision_number is None: raise ValueError url = "/".join([self.baseurl, datasource_id, "revisions", revision_number]) @@ -457,18 +948,83 @@ def delete_revision(self, datasource_id: str, revision_number: str) -> None: def schedule_extract_refresh( self, schedule_id: str, item: DatasourceItem ) -> list["AddResponse"]: # actually should return a task + """ + Adds a task to refresh a data source to an existing server schedule on + Tableau Server. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_data_source_to_schedule + + Parameters + ---------- + schedule_id : str + The unique ID of the schedule to add the task to. + + item : DatasourceItem + The datasource item to add to the schedule. + + Returns + ------- + list[AddResponse] + """ return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item) @api(version="1.0") def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> set[str]: + """ + Adds one or more tags to the specified datasource item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#add_tags_to_data_source + + Parameters + ---------- + item : Union[DatasourceItem, str] + The datasource item or ID to add tags to. + + tags : Union[Iterable[str], str] + The tag or tags to add to the datasource item. + + Returns + ------- + set[str] + The updated set of tags on the datasource item. + """ return super().add_tags(item, tags) @api(version="1.0") def delete_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> None: + """ + Deletes one or more tags from the specified datasource item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#delete_tag_from_data_source + + Parameters + ---------- + item : Union[DatasourceItem, str] + The datasource item or ID to delete tags from. + + tags : Union[Iterable[str], str] + The tag or tags to delete from the datasource item. + + Returns + ------- + None + """ return super().delete_tags(item, tags) @api(version="1.0") def update_tags(self, item: DatasourceItem) -> None: + """ + Updates the tags on the server to match the specified datasource item. + + Parameters + ---------- + item : DatasourceItem + The datasource item to update tags for. + + Returns + ------- + None + """ return super().update_tags(item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[DatasourceItem]: diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index 90e31483b..d2ad517ee 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -1,4 +1,5 @@ import logging +from typing import Callable, Optional, Protocol, TYPE_CHECKING from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError @@ -7,6 +8,15 @@ from tableauserverclient.helpers.logging import logger +if TYPE_CHECKING: + from tableauserverclient.server.request_options import RequestOptions + + +class HasId(Protocol): + @property + def id(self) -> Optional[str]: ... + def _set_data_quality_warnings(self, dqw: Callable[[], list[DQWItem]]): ... + class _DataQualityWarningEndpoint(Endpoint): def __init__(self, parent_srv, resource_type): @@ -14,12 +24,12 @@ def __init__(self, parent_srv, resource_type): self.resource_type = resource_type @property - def baseurl(self): + def baseurl(self) -> str: return "{}/sites/{}/dataQualityWarnings/{}".format( self.parent_srv.baseurl, self.parent_srv.site_id, self.resource_type ) - def add(self, resource, warning): + def add(self, resource: HasId, warning: DQWItem) -> list[DQWItem]: url = f"{self.baseurl}/{resource.id}" add_req = RequestFactory.DQW.add_req(warning) response = self.post_request(url, add_req) @@ -28,7 +38,7 @@ def add(self, resource, warning): return warnings - def update(self, resource, warning): + def update(self, resource: HasId, warning: DQWItem) -> list[DQWItem]: url = f"{self.baseurl}/{resource.id}" add_req = RequestFactory.DQW.update_req(warning) response = self.put_request(url, add_req) @@ -37,11 +47,11 @@ def update(self, resource, warning): return warnings - def clear(self, resource): + def clear(self, resource: HasId) -> None: url = f"{self.baseurl}/{resource.id}" return self.delete_request(url) - def populate(self, item): + def populate(self, item: HasId) -> None: if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -52,7 +62,7 @@ def dqw_fetcher(): item._set_data_quality_warnings(dqw_fetcher) logger.info(f"Populated permissions for item (ID: {item.id})") - def _get_data_quality_warnings(self, item, req_options=None): + def _get_data_quality_warnings(self, item: HasId, req_options: Optional["RequestOptions"] = None) -> list[DQWItem]: url = f"{self.baseurl}/{item.id}" server_response = self.get_request(url, req_options) dqws = DQWItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 9e1160705..21462af5f 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -14,6 +14,7 @@ TypeVar, Union, ) +from typing_extensions import Self from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions @@ -353,3 +354,41 @@ def paginate(self, **kwargs) -> QuerySet[T]: @abc.abstractmethod def get(self, request_options: Optional[RequestOptions] = None) -> tuple[list[T], PaginationItem]: raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}") + + def fields(self: Self, *fields: str) -> QuerySet: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be used in addition to the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + queryset = QuerySet(self) + queryset.request_options.fields |= set(fields) | set(("_default_",)) + return queryset + + def only_fields(self: Self, *fields: str) -> QuerySet: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be replaced by the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + queryset = QuerySet(self) + queryset.request_options.fields |= set(fields) + return queryset diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 77332da3e..ee931c910 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -113,3 +113,7 @@ def __str__(self): class FlowRunCancelledException(FlowRunFailedException): pass + + +class UnsupportedAttributeError(TableauError): + pass diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 1ae10e72d..c1749af40 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -56,6 +56,6 @@ def upload(self, file): request, content_type = RequestFactory.Fileupload.chunk_req(chunk) logger.debug(f"{datetime.timestamp()} created chunk request") fileupload_item = self.append(upload_id, request, content_type) - logger.info(f"\t{datetime.timestamp()} Published {(fileupload_item.file_size / BYTES_PER_MB)}MB") + logger.info(f"\t{datetime.timestamp()} Published {fileupload_item.file_size}MB") logger.info(f"File upload finished (ID: {upload_id})") return upload_id diff --git a/tableauserverclient/server/endpoint/groupsets_endpoint.py b/tableauserverclient/server/endpoint/groupsets_endpoint.py index c7f5ed0e5..8c0ef64f3 100644 --- a/tableauserverclient/server/endpoint/groupsets_endpoint.py +++ b/tableauserverclient/server/endpoint/groupsets_endpoint.py @@ -25,14 +25,14 @@ def baseurl(self) -> str: @api(version="3.22") def get( self, - request_options: Optional[RequestOptions] = None, + req_options: Optional[RequestOptions] = None, result_level: Optional[Literal["members", "local"]] = None, ) -> tuple[list[GroupSetItem], PaginationItem]: logger.info("Querying all group sets on site") url = self.baseurl if result_level: url += f"?resultlevel={result_level}" - server_response = self.get_request(url, request_options) + server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) all_group_set_items = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace) return all_group_set_items, pagination_item diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 027a7ca12..48e91bd74 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -188,7 +188,7 @@ def wait_for_job(self, job_id: Union[str, JobItem], *, timeout: Optional[float] logger.info(f"Job {job_id} Completed: Finish Code: {job.finish_code} - Notes:{job.notes}") - if job.finish_code == JobItem.FinishCode.Success: + if job.finish_code in [JobItem.FinishCode.Success, JobItem.FinishCode.Completed]: return job elif job.finish_code == JobItem.FinishCode.Failed: raise JobFailedException(job) diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index eec4536f9..090d400b6 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -7,7 +7,7 @@ from .endpoint import Endpoint, api, parameter_added_in from .exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory -from tableauserverclient.models import PaginationItem, ScheduleItem, TaskItem +from tableauserverclient.models import PaginationItem, ScheduleItem, TaskItem, ExtractItem from tableauserverclient.helpers.logging import logger @@ -30,6 +30,23 @@ def siteurl(self) -> str: @api(version="2.3") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ScheduleItem], PaginationItem]: + """ + Returns a list of flows, extract, and subscription server schedules on + Tableau Server. For each schedule, the API returns name, frequency, + priority, and other information. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_schedules + + Parameters + ---------- + req_options : Optional[RequestOptions] + Filtering and paginating options for request. + + Returns + ------- + Tuple[List[ScheduleItem], PaginationItem] + A tuple of list of ScheduleItem and PaginationItem + """ logger.info("Querying all schedules") url = self.baseurl server_response = self.get_request(url, req_options) @@ -38,7 +55,22 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Sche return all_schedule_items, pagination_item @api(version="3.8") - def get_by_id(self, schedule_id): + def get_by_id(self, schedule_id: str) -> ScheduleItem: + """ + Returns detailed information about the specified server schedule. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#get-schedule + + Parameters + ---------- + schedule_id : str + The ID of the schedule to get information for. + + Returns + ------- + ScheduleItem + The schedule item that corresponds to the given ID. + """ if not schedule_id: error = "No Schedule ID provided" raise ValueError(error) @@ -49,6 +81,20 @@ def get_by_id(self, schedule_id): @api(version="2.3") def delete(self, schedule_id: str) -> None: + """ + Deletes the specified schedule from the server. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#delete_schedule + + Parameters + ---------- + schedule_id : str + The ID of the schedule to delete. + + Returns + ------- + None + """ if not schedule_id: error = "Schedule ID undefined" raise ValueError(error) @@ -58,6 +104,23 @@ def delete(self, schedule_id: str) -> None: @api(version="2.3") def update(self, schedule_item: ScheduleItem) -> ScheduleItem: + """ + Modifies settings for the specified server schedule, including the name, + priority, and frequency details on Tableau Server. For Tableau Cloud, + see the tasks and subscritpions API. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#update_schedule + + Parameters + ---------- + schedule_item : ScheduleItem + The schedule item to update. + + Returns + ------- + ScheduleItem + The updated schedule item. + """ if not schedule_item.id: error = "Schedule item missing ID." raise MissingRequiredFieldError(error) @@ -71,6 +134,20 @@ def update(self, schedule_item: ScheduleItem) -> ScheduleItem: @api(version="2.3") def create(self, schedule_item: ScheduleItem) -> ScheduleItem: + """ + Creates a new server schedule on Tableau Server. For Tableau Cloud, use + the tasks and subscriptions API. + + Parameters + ---------- + schedule_item : ScheduleItem + The schedule item to create. + + Returns + ------- + ScheduleItem + The newly created schedule. + """ if schedule_item.interval_item is None: error = "Interval item must be defined." raise MissingRequiredFieldError(error) @@ -92,6 +169,41 @@ def add_to_schedule( flow: Optional["FlowItem"] = None, task_type: Optional[str] = None, ) -> list[AddResponse]: + """ + Adds a workbook, datasource, or flow to a schedule on Tableau Server. + Only one of workbook, datasource, or flow can be passed in at a time. + + The task type is optional and will default to ExtractRefresh if a + workbook or datasource is passed in, and RunFlow if a flow is passed in. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_workbook_to_schedule + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_data_source_to_schedule + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#add_flow_task_to_schedule + + Parameters + ---------- + schedule_id : str + The ID of the schedule to add the item to. + + workbook : Optional[WorkbookItem] + The workbook to add to the schedule. + + datasource : Optional[DatasourceItem] + The datasource to add to the schedule. + + flow : Optional[FlowItem] + The flow to add to the schedule. + + task_type : Optional[str] + The type of task to add to the schedule. If not provided, it will + default to ExtractRefresh if a workbook or datasource is passed in, + and RunFlow if a flow is passed in. + + Returns + ------- + list[AddResponse] + A list of responses for each item added to the schedule. + """ # There doesn't seem to be a good reason to allow one item of each type? if workbook and datasource: warnings.warn("Passing in multiple items for add_to_schedule will be deprecated", PendingDeprecationWarning) @@ -149,3 +261,21 @@ def _add_to( ) else: return OK + + @api(version="2.3") + def get_extract_refresh_tasks( + self, schedule_id: str, req_options: Optional["RequestOptions"] = None + ) -> tuple[list["ExtractItem"], "PaginationItem"]: + """Get all extract refresh tasks for the specified schedule.""" + if not schedule_id: + error = "Schedule ID undefined" + raise ValueError(error) + + logger.info(f"Querying extract refresh tasks for schedule (ID: {schedule_id})") + url = f"{self.siteurl}/{schedule_id}/extracts" + server_response = self.get_request(url, req_options) + + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + extract_items = ExtractItem.from_response(server_response.content, self.parent_srv.namespace) + + return extract_items, pagination_item diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 55d2a5ad0..e2316fbb8 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -4,7 +4,7 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory -from tableauserverclient.models import SiteItem, PaginationItem +from tableauserverclient.models import SiteAuthConfiguration, SiteItem, PaginationItem from tableauserverclient.helpers.logging import logger @@ -418,3 +418,20 @@ def re_encrypt_extracts(self, site_id: str) -> None: empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) + + @api(version="3.24") + def list_auth_configurations(self) -> list[SiteAuthConfiguration]: + """ + Lists all authentication configurations on the current site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_site.htm#list_authentication_configurations_site + + Returns + ------- + list[SiteAuthConfiguration] + A list of authentication configurations on the current site. + """ + url = f"{self.baseurl}/{self.parent_srv.site_id}/site-auth-configurations" + server_response = self.get_request(url) + auth_configurations = SiteAuthConfiguration.from_response(server_response.content, self.parent_srv.namespace) + return auth_configurations diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index 120d3ba9c..ad80e7d0e 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,7 +1,8 @@ import logging -from typing import Union +from typing import Optional, Union, TYPE_CHECKING from collections.abc import Iterable +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint from tableauserverclient.server.endpoint.endpoint import api, Endpoint from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -12,6 +13,10 @@ from tableauserverclient.server.pager import Pager from tableauserverclient.helpers.logging import logger +from tableauserverclient.server.request_options import RequestOptions + +if TYPE_CHECKING: + from tableauserverclient.models import DQWItem, PermissionsRule class Tables(Endpoint, TaggingMixin[TableItem]): @@ -22,11 +27,29 @@ def __init__(self, parent_srv): self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "table") @property - def baseurl(self): + def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tables" @api(version="3.5") - def get(self, req_options=None): + def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[TableItem], PaginationItem]: + """ + Get information about all tables on the site. Endpoint is paginated, and + will return a default of 100 items per page. Use the `req_options` + parameter to customize the request. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_tables + + Parameters + ---------- + req_options : RequestOptions, optional + Options to customize the request. If not provided, defaults to None. + + Returns + ------- + tuple[list[TableItem], PaginationItem] + A tuple containing a list of TableItem objects and a PaginationItem + object. + """ logger.info("Querying all tables on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -36,7 +59,27 @@ def get(self, req_options=None): # Get 1 table @api(version="3.5") - def get_by_id(self, table_id): + def get_by_id(self, table_id: str) -> TableItem: + """ + Get information about a single table on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_table + + Parameters + ---------- + table_id : str + The ID of the table to retrieve. + + Returns + ------- + TableItem + A TableItem object representing the table. + + Raises + ------ + ValueError + If the table ID is not provided. + """ if not table_id: error = "table ID undefined." raise ValueError(error) @@ -46,7 +89,24 @@ def get_by_id(self, table_id): return TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="3.5") - def delete(self, table_id): + def delete(self, table_id: str) -> None: + """ + Delete a single table from the server. + + Parameters + ---------- + table_id : str + The ID of the table to delete. + + Returns + ------- + None + + Raises + ------ + ValueError + If the table ID is not provided. + """ if not table_id: error = "Database ID undefined." raise ValueError(error) @@ -55,7 +115,27 @@ def delete(self, table_id): logger.info(f"Deleted single table (ID: {table_id})") @api(version="3.5") - def update(self, table_item): + def update(self, table_item: TableItem) -> TableItem: + """ + Update a table on the server. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_table + + Parameters + ---------- + table_item : TableItem + The TableItem object to update. + + Returns + ------- + TableItem + The updated TableItem object. + + Raises + ------ + MissingRequiredFieldError + If the table item is missing an ID. + """ if not table_item.id: error = "table item missing ID." raise MissingRequiredFieldError(error) @@ -69,21 +149,46 @@ def update(self, table_item): # Get all columns of the table @api(version="3.5") - def populate_columns(self, table_item, req_options=None): + def populate_columns(self, table_item: TableItem, req_options: Optional[RequestOptions] = None) -> None: + """ + Populate the columns of a table item. Sets a fetcher function to + retrieve the columns when needed. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_columns + + Parameters + ---------- + table_item : TableItem + The TableItem object to populate columns for. + + req_options : RequestOptions, optional + Options to customize the request. If not provided, defaults to None. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the table item is missing an ID. + """ if not table_item.id: error = "Table item missing ID. table must be retrieved from server first." raise MissingRequiredFieldError(error) def column_fetcher(): return Pager( - lambda options: self._get_columns_for_table(table_item, options), + lambda options: self._get_columns_for_table(table_item, options), # type: ignore req_options, ) table_item._set_columns(column_fetcher) logger.info(f"Populated columns for table (ID: {table_item.id}") - def _get_columns_for_table(self, table_item, req_options=None): + def _get_columns_for_table( + self, table_item: TableItem, req_options: Optional[RequestOptions] = None + ) -> tuple[list[ColumnItem], PaginationItem]: url = f"{self.baseurl}/{table_item.id}/columns" server_response = self.get_request(url, req_options) columns = ColumnItem.from_response(server_response.content, self.parent_srv.namespace) @@ -91,7 +196,25 @@ def _get_columns_for_table(self, table_item, req_options=None): return columns, pagination_item @api(version="3.5") - def update_column(self, table_item, column_item): + def update_column(self, table_item: TableItem, column_item: ColumnItem) -> ColumnItem: + """ + Update the description of a column in a table. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_column + + Parameters + ---------- + table_item : TableItem + The TableItem object representing the table. + + column_item : ColumnItem + The ColumnItem object representing the column to update. + + Returns + ------- + ColumnItem + The updated ColumnItem object. + """ url = f"{self.baseurl}/{table_item.id}/columns/{column_item.id}" update_req = RequestFactory.Column.update_req(column_item) server_response = self.put_request(url, update_req) @@ -101,31 +224,31 @@ def update_column(self, table_item, column_item): return column @api(version="3.5") - def populate_permissions(self, item): + def populate_permissions(self, item: TableItem) -> None: self._permissions.populate(item) @api(version="3.5") - def update_permissions(self, item, rules): + def update_permissions(self, item: TableItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._permissions.update(item, rules) @api(version="3.5") - def delete_permission(self, item, rules): + def delete_permission(self, item: TableItem, rules: list[PermissionsRule]) -> None: return self._permissions.delete(item, rules) @api(version="3.5") - def populate_dqw(self, item): + def populate_dqw(self, item: TableItem) -> None: self._data_quality_warnings.populate(item) @api(version="3.5") - def update_dqw(self, item, warning): + def update_dqw(self, item: TableItem, warning: "DQWItem") -> list["DQWItem"]: return self._data_quality_warnings.update(item, warning) @api(version="3.5") - def add_dqw(self, item, warning): + def add_dqw(self, item: TableItem, warning: "DQWItem") -> list["DQWItem"]: return self._data_quality_warnings.add(item, warning) @api(version="3.5") - def delete_dqw(self, item): + def delete_dqw(self, item: TableItem) -> None: self._data_quality_warnings.clear(item) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index d81907ae9..17af21a03 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -87,7 +87,7 @@ def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserIt if req_options is None: req_options = RequestOptions() - req_options._all_fields = True + req_options.all_fields = True url = self.baseurl server_response = self.get_request(url, req_options) @@ -381,10 +381,15 @@ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[Us # Get workbooks for user @api(version="2.0") - def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: + def populate_workbooks( + self, user_item: UserItem, req_options: Optional[RequestOptions] = None, owned_only: bool = False + ) -> None: """ Returns information about the workbooks that the specified user owns - and has Read (view) permissions for. + or has Read (view) permissions for. If owned_only is set to True, + only the workbooks that the user owns are returned. If owned_only is + set to False, all workbooks that the user has Read (view) permissions + for are returned. This method retrieves the workbook information for the specified user. The REST API is designed to return only the information you ask for @@ -402,6 +407,10 @@ def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestO req_options : Optional[RequestOptions] Optional request options to filter and sort the results. + owned_only : bool, default=False + If True, only the workbooks that the user owns are returned. + If False, all workbooks that the user has Read (view) permissions + Returns ------- None @@ -423,14 +432,22 @@ def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestO raise MissingRequiredFieldError(error) def wb_pager(): - return Pager(lambda options: self._get_wbs_for_user(user_item, options), req_options) + def func(req_options): + return self._get_wbs_for_user(user_item, req_options, owned_only=owned_only) + + return Pager(func, req_options) user_item._set_workbooks(wb_pager) def _get_wbs_for_user( - self, user_item: UserItem, req_options: Optional[RequestOptions] = None + self, + user_item: UserItem, + req_options: Optional[RequestOptions] = None, + owned_only: bool = False, ) -> tuple[list[WorkbookItem], PaginationItem]: url = f"{self.baseurl}/{user_item.id}/workbooks" + if owned_only: + url += "?ownedBy=true" server_response = self.get_request(url, req_options) logger.info(f"Populated workbooks for user (ID: {user_item.id})") workbook_item = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 12b386876..9d1c8b00f 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -3,7 +3,7 @@ from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api -from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError, UnsupportedAttributeError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin from tableauserverclient.server.query import QuerySet @@ -171,6 +171,10 @@ def populate_image(self, view_item: ViewItem, req_options: Optional["ImageReques def image_fetcher(): return self._get_view_image(view_item, req_options) + if not self.parent_srv.check_at_least_version("3.23") and req_options is not None: + if req_options.viz_height or req_options.viz_width: + raise UnsupportedAttributeError("viz_height and viz_width are only supported in 3.23+") + view_item._set_image(image_fetcher) logger.info(f"Populated image for view (ID: {view_item.id})") diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 4fdcf075b..bf4088b9f 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -11,7 +11,11 @@ from tableauserverclient.server.query import QuerySet from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in -from tableauserverclient.server.endpoint.exceptions import InternalServerError, MissingRequiredFieldError +from tableauserverclient.server.endpoint.exceptions import ( + InternalServerError, + MissingRequiredFieldError, + UnsupportedAttributeError, +) from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin @@ -34,7 +38,7 @@ if TYPE_CHECKING: from tableauserverclient.server import Server - from tableauserverclient.server.request_options import RequestOptions + from tableauserverclient.server.request_options import RequestOptions, PDFRequestOptions, PPTXRequestOptions from tableauserverclient.models import DatasourceItem from tableauserverclient.server.endpoint.schedules_endpoint import AddResponse @@ -136,7 +140,7 @@ def refresh(self, workbook_item: Union[WorkbookItem, str], incremental: bool = F """ id_ = getattr(workbook_item, "id", workbook_item) url = f"{self.baseurl}/{id_}/refresh" - refresh_req = RequestFactory.Task.refresh_req(incremental) + refresh_req = RequestFactory.Task.refresh_req(incremental, self.parent_srv) server_response = self.post_request(url, refresh_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job @@ -472,11 +476,12 @@ def _get_workbook_connections( connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections - # Get the pdf of the entire workbook if its tabs are enabled, pdf of the default view if its tabs are disabled @api(version="3.4") - def populate_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: + def populate_pdf(self, workbook_item: WorkbookItem, req_options: Optional["PDFRequestOptions"] = None) -> None: """ - Populates the PDF for the specified workbook item. + Populates the PDF for the specified workbook item. Get the pdf of the + entire workbook if its tabs are enabled, pdf of the default view if its + tabs are disabled. This method populates a PDF with image(s) of the workbook view(s) you specify. @@ -488,7 +493,7 @@ def populate_pdf(self, workbook_item: WorkbookItem, req_options: Optional["Reque workbook_item : WorkbookItem The workbook item to populate the PDF for. - req_options : RequestOptions, optional + req_options : PDFRequestOptions, optional (Optional) You can pass in request options to specify the page type and orientation of the PDF content, as well as the maximum age of the PDF rendered on the server. See PDFRequestOptions class for more @@ -510,17 +515,26 @@ def populate_pdf(self, workbook_item: WorkbookItem, req_options: Optional["Reque def pdf_fetcher() -> bytes: return self._get_wb_pdf(workbook_item, req_options) + if not self.parent_srv.check_at_least_version("3.23") and req_options is not None: + if req_options.view_filters or req_options.view_parameters: + raise UnsupportedAttributeError("view_filters and view_parameters are only supported in 3.23+") + + if req_options.viz_height or req_options.viz_width: + raise UnsupportedAttributeError("viz_height and viz_width are only supported in 3.23+") + workbook_item._set_pdf(pdf_fetcher) logger.info(f"Populated pdf for workbook (ID: {workbook_item.id})") - def _get_wb_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: + def _get_wb_pdf(self, workbook_item: WorkbookItem, req_options: Optional["PDFRequestOptions"]) -> bytes: url = f"{self.baseurl}/{workbook_item.id}/pdf" server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @api(version="3.8") - def populate_powerpoint(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: + def populate_powerpoint( + self, workbook_item: WorkbookItem, req_options: Optional["PPTXRequestOptions"] = None + ) -> None: """ Populates the PowerPoint for the specified workbook item. @@ -561,7 +575,7 @@ def pptx_fetcher() -> bytes: workbook_item._set_powerpoint(pptx_fetcher) logger.info(f"Populated powerpoint for workbook (ID: {workbook_item.id})") - def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: + def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["PPTXRequestOptions"]) -> bytes: url = f"{self.baseurl}/{workbook_item.id}/powerpoint" server_response = self.get_request(url, req_options) pptx = server_response.content diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 801ad4a13..5137cee52 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -208,6 +208,42 @@ def paginate(self: Self, **kwargs) -> Self: self.request_options.pagesize = kwargs["page_size"] return self + def fields(self: Self, *fields: str) -> Self: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be used in addition to the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + self.request_options.fields |= set(fields) | set(("_default_")) + return self + + def only_fields(self: Self, *fields: str) -> Self: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be replaced by the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + self.request_options.fields |= set(fields) + return self + @staticmethod def _parse_shorthand_filter(key: str) -> tuple[str, str]: tokens = key.split("__", 1) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 79ac6e4ca..c898004f7 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -913,6 +913,8 @@ def update_req(self, user_item: UserItem, password: Optional[str]) -> bytes: user_element.attrib["authSetting"] = user_item.auth_setting if password: user_element.attrib["password"] = password + if user_item.idp_configuration_id is not None: + user_element.attrib["idpConfigurationId"] = user_item.idp_configuration_id return ET.tostring(xml_request) def add_req(self, user_item: UserItem) -> bytes: @@ -929,6 +931,9 @@ def add_req(self, user_item: UserItem) -> bytes: if user_item.auth_setting: user_element.attrib["authSetting"] = user_item.auth_setting + + if user_item.idp_configuration_id is not None: + user_element.attrib["idpConfigurationId"] = user_item.idp_configuration_id return ET.tostring(xml_request) @@ -1118,11 +1123,17 @@ def run_req(self, xml_request: ET.Element, task_item: Any) -> None: pass @_tsrequest_wrapped - def refresh_req(self, xml_request: ET.Element, incremental: bool = False) -> bytes: - task_element = ET.SubElement(xml_request, "extractRefresh") - if incremental: - task_element.attrib["incremental"] = "true" - return ET.tostring(xml_request) + def refresh_req( + self, xml_request: ET.Element, incremental: bool = False, parent_srv: Optional["Server"] = None + ) -> Optional[bytes]: + if parent_srv is not None and parent_srv.check_at_least_version("3.25"): + task_element = ET.SubElement(xml_request, "extractRefresh") + if incremental: + task_element.attrib["incremental"] = "true" + return ET.tostring(xml_request) + elif incremental: + raise ValueError("Incremental refresh is only supported in 3.25+") + return None @_tsrequest_wrapped def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") -> bytes: diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index c37c0ce42..4a104255f 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,5 +1,6 @@ import sys from typing import Optional +import warnings from typing_extensions import Self @@ -62,8 +63,21 @@ def __init__(self, pagenumber=1, pagesize=None): self.pagesize = pagesize or config.PAGE_SIZE self.sort = set() self.filter = set() + self.fields = set() # This is private until we expand all of our parsers to handle the extra fields - self._all_fields = False + self.all_fields = False + + @property + def _all_fields(self) -> bool: + return self.all_fields + + @_all_fields.setter + def _all_fields(self, value): + warnings.warn( + "Directly setting _all_fields is deprecated, please use the all_fields property instead.", + DeprecationWarning, + ) + self.all_fields = value def get_query_params(self) -> dict: params = {} @@ -75,12 +89,14 @@ def get_query_params(self) -> dict: filter_options = (str(filter_item) for filter_item in self.filter) ordered_filter_options = sorted(filter_options) params["filter"] = ",".join(ordered_filter_options) - if self._all_fields: + if self.all_fields: params["fields"] = "_all_" if self.pagenumber: params["pageNumber"] = self.pagenumber if self.pagesize: params["pageSize"] = self.pagesize + if self.fields: + params["fields"] = ",".join(self.fields) return params def page_size(self, page_size): @@ -181,6 +197,116 @@ class Direction: Desc = "desc" Asc = "asc" + class SelectFields: + class Common: + All = "_all_" + Default = "_default_" + + class ContentsCounts: + ProjectCount = "contentsCounts.projectCount" + ViewCount = "contentsCounts.viewCount" + DatasourceCount = "contentsCounts.datasourceCount" + WorkbookCount = "contentsCounts.workbookCount" + + class Datasource: + ContentUrl = "datasource.contentUrl" + ID = "datasource.id" + Name = "datasource.name" + Type = "datasource.type" + Description = "datasource.description" + CreatedAt = "datasource.createdAt" + UpdatedAt = "datasource.updatedAt" + EncryptExtracts = "datasource.encryptExtracts" + IsCertified = "datasource.isCertified" + UseRemoteQueryAgent = "datasource.useRemoteQueryAgent" + WebPageURL = "datasource.webpageUrl" + Size = "datasource.size" + Tag = "datasource.tag" + FavoritesTotal = "datasource.favoritesTotal" + DatabaseName = "datasource.databaseName" + ConnectedWorkbooksCount = "datasource.connectedWorkbooksCount" + HasAlert = "datasource.hasAlert" + HasExtracts = "datasource.hasExtracts" + IsPublished = "datasource.isPublished" + ServerName = "datasource.serverName" + + class Favorite: + Label = "favorite.label" + ParentProjectName = "favorite.parentProjectName" + TargetOwnerName = "favorite.targetOwnerName" + + class Group: + ID = "group.id" + Name = "group.name" + DomainName = "group.domainName" + UserCount = "group.userCount" + MinimumSiteRole = "group.minimumSiteRole" + + class Job: + ID = "job.id" + Status = "job.status" + CreatedAt = "job.createdAt" + StartedAt = "job.startedAt" + EndedAt = "job.endedAt" + Priority = "job.priority" + JobType = "job.jobType" + Title = "job.title" + Subtitle = "job.subtitle" + + class Owner: + ID = "owner.id" + Name = "owner.name" + FullName = "owner.fullName" + SiteRole = "owner.siteRole" + LastLogin = "owner.lastLogin" + Email = "owner.email" + + class Project: + ID = "project.id" + Name = "project.name" + Description = "project.description" + CreatedAt = "project.createdAt" + UpdatedAt = "project.updatedAt" + ContentPermissions = "project.contentPermissions" + ParentProjectID = "project.parentProjectId" + TopLevelProject = "project.topLevelProject" + Writeable = "project.writeable" + + class User: + ExternalAuthUserId = "user.externalAuthUserId" + ID = "user.id" + Name = "user.name" + SiteRole = "user.siteRole" + LastLogin = "user.lastLogin" + FullName = "user.fullName" + Email = "user.email" + AuthSetting = "user.authSetting" + + class View: + ID = "view.id" + Name = "view.name" + ContentUrl = "view.contentUrl" + CreatedAt = "view.createdAt" + UpdatedAt = "view.updatedAt" + Tags = "view.tags" + SheetType = "view.sheetType" + Usage = "view.usage" + + class Workbook: + ID = "workbook.id" + Description = "workbook.description" + Name = "workbook.name" + ContentUrl = "workbook.contentUrl" + ShowTabs = "workbook.showTabs" + Size = "workbook.size" + CreatedAt = "workbook.createdAt" + UpdatedAt = "workbook.updatedAt" + SheetCount = "workbook.sheetCount" + HasExtracts = "workbook.hasExtracts" + Tags = "workbook.tags" + WebpageUrl = "workbook.webpageUrl" + DefaultViewId = "workbook.defaultViewId" + """ These options can be used by methods that are fetching data exported from a specific content item @@ -385,6 +511,8 @@ class PDFRequestOptions(_ImagePDFCommonExportOptions): Options that can be used when exporting a view to PDF. Set the maxage to control the age of the data exported. Filters to the underlying data can be applied using the `vf` and `parameter` methods. + vf and parameter filters are only supported in API version 3.23 and later. + Parameters ---------- page_type: str, optional @@ -438,3 +566,35 @@ def get_query_params(self) -> dict: params["orientation"] = self.orientation return params + + +class PPTXRequestOptions(RequestOptionsBase): + """ + Options that can be used when exporting a view to PPTX. Set the maxage to control the age of the data exported. + + Parameters + ---------- + maxage: int, optional + The maximum age of the data to export. Shortest possible duration is 1 + minute. No upper limit. Default is -1, which means no limit. + """ + + def __init__(self, maxage=-1): + super().__init__() + self.max_age = maxage + + @property + def max_age(self) -> int: + return self._max_age + + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value): + self._max_age = value + + def get_query_params(self): + params = {} + if self.max_age != -1: + params["maxAge"] = self.max_age + + return params diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 30c635e31..d5d163db3 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -2,6 +2,7 @@ import requests import urllib3 +import ssl from defusedxml.ElementTree import fromstring, ParseError from packaging.version import Version @@ -91,6 +92,13 @@ class Server: and a later version of the REST API. For more information, see REST API Versions. + http_options : dict, optional + Additional options to pass to the requests library when making HTTP requests. + + session_factory : callable, optional + A factory function that returns a requests.Session object. If not provided, + requests.session is used. + Examples -------- >>> import tableauserverclient as TSC @@ -107,6 +115,16 @@ class Server: >>> # for example, 2.8 >>> # server.version = '2.8' + >>> # if connecting to an older Tableau Server with weak DH keys (Python 3.12+ only) + >>> server.configure_ssl(allow_weak_dh=True) # Note: reduces security + + Notes + ----- + When using Python 3.12 or later with older versions of Tableau Server, you may encounter + SSL errors related to weak Diffie-Hellman keys. This is because newer Python versions + enforce stronger security requirements. You can temporarily work around this using + configure_ssl(allow_weak_dh=True), but this reduces security and should only be used + as a temporary measure until the server can be upgraded. """ class PublishMode: @@ -125,6 +143,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self._auth_token = None self._site_id = None self._user_id = None + self._ssl_context = None # TODO: this needs to change to default to https, but without breaking existing code if not server_address.startswith("http://") and not server_address.startswith("https://"): @@ -313,3 +332,26 @@ def session(self): def is_signed_in(self): return self._auth_token is not None + + def configure_ssl(self, *, allow_weak_dh=False): + """Configure SSL/TLS settings for the server connection. + + Parameters + ---------- + allow_weak_dh : bool, optional + If True, allows connections to servers with DH keys that are considered too small by modern Python versions. + WARNING: This reduces security and should only be used as a temporary workaround. + """ + if allow_weak_dh: + logger.warning( + "WARNING: Allowing weak Diffie-Hellman keys. This reduces security and should only be used temporarily." + ) + self._ssl_context = ssl.create_default_context() + # Allow weak DH keys by setting minimum key size to 512 bits (default is 1024 in Python 3.12+) + self._ssl_context.set_dh_parameters(min_key_bits=512) + self.add_http_options({"verify": self._ssl_context}) + else: + self._ssl_context = None + # Remove any custom SSL context if we're reverting to default settings + if "verify" in self._http_options: + del self._http_options["verify"] diff --git a/test/assets/datasource_get_all_fields.xml b/test/assets/datasource_get_all_fields.xml new file mode 100644 index 000000000..46c4396d3 --- /dev/null +++ b/test/assets/datasource_get_all_fields.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/test/assets/group_get_all_fields.xml b/test/assets/group_get_all_fields.xml new file mode 100644 index 000000000..0118250e1 --- /dev/null +++ b/test/assets/group_get_all_fields.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/job_get_by_id_completed.xml b/test/assets/job_get_by_id_completed.xml new file mode 100644 index 000000000..95ca29b49 --- /dev/null +++ b/test/assets/job_get_by_id_completed.xml @@ -0,0 +1,14 @@ + + + + + Job detail notes + + + More detail + + + \ No newline at end of file diff --git a/test/assets/project_get_all_fields.xml b/test/assets/project_get_all_fields.xml new file mode 100644 index 000000000..d71ebd922 --- /dev/null +++ b/test/assets/project_get_all_fields.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/assets/schedule_get_extract_refresh_tasks.xml b/test/assets/schedule_get_extract_refresh_tasks.xml new file mode 100644 index 000000000..48906dde6 --- /dev/null +++ b/test/assets/schedule_get_extract_refresh_tasks.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/site_auth_configurations.xml b/test/assets/site_auth_configurations.xml new file mode 100644 index 000000000..c81d179ac --- /dev/null +++ b/test/assets/site_auth_configurations.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/test/assets/user_get_all_fields.xml b/test/assets/user_get_all_fields.xml new file mode 100644 index 000000000..7e9a62568 --- /dev/null +++ b/test/assets/user_get_all_fields.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/test/assets/view_get_all_fields.xml b/test/assets/view_get_all_fields.xml new file mode 100644 index 000000000..236ebd726 --- /dev/null +++ b/test/assets/view_get_all_fields.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/assets/virtual_connection_populate_connections2.xml b/test/assets/virtual_connection_populate_connections2.xml new file mode 100644 index 000000000..f0ad2646d --- /dev/null +++ b/test/assets/virtual_connection_populate_connections2.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/assets/workbook_get_all_fields.xml b/test/assets/workbook_get_all_fields.xml new file mode 100644 index 000000000..007b79338 --- /dev/null +++ b/test/assets/workbook_get_all_fields.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/request_factory/test_task_requests.py b/test/request_factory/test_task_requests.py new file mode 100644 index 000000000..6287fa6ea --- /dev/null +++ b/test/request_factory/test_task_requests.py @@ -0,0 +1,47 @@ +import unittest +import xml.etree.ElementTree as ET +from unittest.mock import Mock +from tableauserverclient.server.request_factory import TaskRequest + + +class TestTaskRequest(unittest.TestCase): + def setUp(self): + self.task_request = TaskRequest() + self.xml_request = ET.Element("tsRequest") + + def test_refresh_req_default(self): + result = self.task_request.refresh_req() + self.assertEqual(result, ET.tostring(self.xml_request)) + + def test_refresh_req_incremental(self): + with self.assertRaises(ValueError): + self.task_request.refresh_req(incremental=True) + + def test_refresh_req_with_parent_srv_version_3_25(self): + parent_srv = Mock() + parent_srv.check_at_least_version.return_value = True + result = self.task_request.refresh_req(incremental=True, parent_srv=parent_srv) + expected_xml = ET.Element("tsRequest") + task_element = ET.SubElement(expected_xml, "extractRefresh") + task_element.attrib["incremental"] = "true" + self.assertEqual(result, ET.tostring(expected_xml)) + + def test_refresh_req_with_parent_srv_version_3_25_non_incremental(self): + parent_srv = Mock() + parent_srv.check_at_least_version.return_value = True + result = self.task_request.refresh_req(incremental=False, parent_srv=parent_srv) + expected_xml = ET.Element("tsRequest") + ET.SubElement(expected_xml, "extractRefresh") + self.assertEqual(result, ET.tostring(expected_xml)) + + def test_refresh_req_with_parent_srv_version_below_3_25(self): + parent_srv = Mock() + parent_srv.check_at_least_version.return_value = False + with self.assertRaises(ValueError): + self.task_request.refresh_req(incremental=True, parent_srv=parent_srv) + + def test_refresh_req_with_parent_srv_version_below_3_25_non_incremental(self): + parent_srv = Mock() + parent_srv.check_at_least_version.return_value = False + result = self.task_request.refresh_req(incremental=False, parent_srv=parent_srv) + self.assertEqual(result, ET.tostring(self.xml_request)) diff --git a/test/test_datasource.py b/test/test_datasource.py index e8a95722b..a604ba8b0 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -10,7 +10,7 @@ import tableauserverclient as TSC from tableauserverclient import ConnectionItem -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime from tableauserverclient.server.endpoint.exceptions import InternalServerError from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads from tableauserverclient.server.request_factory import RequestFactory @@ -20,6 +20,7 @@ GET_XML = "datasource_get.xml" GET_EMPTY_XML = "datasource_get_empty.xml" GET_BY_ID_XML = "datasource_get_by_id.xml" +GET_XML_ALL_FIELDS = "datasource_get_all_fields.xml" POPULATE_CONNECTIONS_XML = "datasource_populate_connections.xml" POPULATE_PERMISSIONS_XML = "datasource_populate_permissions.xml" PUBLISH_XML = "datasource_publish.xml" @@ -366,6 +367,25 @@ def test_refresh_object(self) -> None: # We only check the `id`; remaining fields are already tested in `test_refresh_id` self.assertEqual("7c3d599e-949f-44c3-94a1-f30ba85757e4", new_job.id) + def test_datasource_refresh_request_empty(self) -> None: + self.server.version = "2.8" + self.baseurl = self.server.datasources.baseurl + item = TSC.DatasourceItem("") + item._id = "1234" + text = read_xml_asset(REFRESH_XML) + + def match_request_body(request): + try: + root = fromstring(request.body) + assert root.tag == "tsRequest" + assert len(root) == 0 + return True + except Exception: + return False + + with requests_mock.mock() as m: + m.post(f"{self.baseurl}/1234/refresh", text=text, additional_matcher=match_request_body) + def test_update_hyper_data_datasource_object(self) -> None: """Calling `update_hyper_data` with a `DatasourceItem` should update that datasource""" self.server.version = "3.13" @@ -714,3 +734,39 @@ def test_bad_download_response(self) -> None: ) file_path = self.server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td) self.assertTrue(os.path.exists(file_path)) + + def test_get_datasource_all_fields(self) -> None: + ro = TSC.RequestOptions() + ro.all_fields = True + with requests_mock.mock() as m: + m.get(f"{self.baseurl}?fields=_all_", text=read_xml_asset(GET_XML_ALL_FIELDS)) + datasources, _ = self.server.datasources.get(req_options=ro) + + assert datasources[0].connected_workbooks_count == 0 + assert datasources[0].content_url == "SuperstoreDatasource" + assert datasources[0].created_at == parse_datetime("2024-02-14T04:42:13Z") + assert not datasources[0].encrypt_extracts + assert datasources[0].favorites_total == 0 + assert not datasources[0].has_alert + assert not datasources[0].has_extracts + assert datasources[0].id == "a71cdd15-3a23-4ec1-b3ce-9956f5e00bb7" + assert not datasources[0].certified + assert datasources[0].is_published + assert datasources[0].name == "Superstore Datasource" + assert datasources[0].size == 1 + assert datasources[0].datasource_type == "excel-direct" + assert datasources[0].updated_at == parse_datetime("2024-02-14T04:42:14Z") + assert not datasources[0].use_remote_query_agent + assert datasources[0].server_name == "localhost" + assert datasources[0].webpage_url == "https://10ax.online.tableau.com/#/site/example/datasources/3566752" + assert isinstance(datasources[0].project, TSC.ProjectItem) + assert datasources[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert datasources[0].project.name == "Samples" + assert datasources[0].project.description == "This project includes automatically uploaded samples." + assert datasources[0].owner.email == "bob@example.com" + assert isinstance(datasources[0].owner, TSC.UserItem) + assert datasources[0].owner.fullname == "Bob Smith" + assert datasources[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert datasources[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert datasources[0].owner.name == "bob@example.com" + assert datasources[0].owner.site_role == "SiteAdministratorCreator" diff --git a/test/test_group.py b/test/test_group.py index 41b5992be..b3de07963 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -10,6 +10,7 @@ # TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = os.path.join(TEST_ASSET_DIR, "group_get.xml") +GET_XML_ALL_FIELDS = TEST_ASSET_DIR / "group_get_all_fields.xml" POPULATE_USERS = os.path.join(TEST_ASSET_DIR, "group_populate_users.xml") POPULATE_USERS_EMPTY = os.path.join(TEST_ASSET_DIR, "group_populate_users_empty.xml") ADD_USER = os.path.join(TEST_ASSET_DIR, "group_add_user.xml") @@ -310,3 +311,25 @@ def test_update_ad_async(self) -> None: self.assertEqual(job.id, "c2566efc-0767-4f15-89cb-56acb4349c1b") self.assertEqual(job.mode, "Asynchronous") self.assertEqual(job.type, "GroupSync") + + def test_get_all_fields(self) -> None: + ro = TSC.RequestOptions() + ro.all_fields = True + self.server.version = "3.21" + self.baseurl = self.server.groups.baseurl + with requests_mock.mock() as m: + m.get(f"{self.baseurl}?fields=_all_", text=GET_XML_ALL_FIELDS.read_text()) + groups, pages = self.server.groups.get(req_options=ro) + + assert pages.total_available == 3 + assert len(groups) == 3 + assert groups[0].id == "28c5b855-16df-482f-ad0b-428c1df58859" + assert groups[0].name == "All Users" + assert groups[0].user_count == 2 + assert groups[0].domain_name == "local" + assert groups[1].id == "ace1ee2d-e7dd-4d7a-9504-a1ccaa5212ea" + assert groups[1].name == "group1" + assert groups[1].user_count == 1 + assert groups[2].id == "baf0ed9d-c25d-4114-97ed-5232b8a732fd" + assert groups[2].name == "test" + assert groups[2].user_count == 0 diff --git a/test/test_job.py b/test/test_job.py index 20b238764..b3d7007aa 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -11,6 +11,7 @@ GET_XML = "job_get.xml" GET_BY_ID_XML = "job_get_by_id.xml" +GET_BY_ID_COMPLETED_XML = "job_get_by_id_completed.xml" GET_BY_ID_FAILED_XML = "job_get_by_id_failed.xml" GET_BY_ID_CANCELLED_XML = "job_get_by_id_cancelled.xml" GET_BY_ID_INPROGRESS_XML = "job_get_by_id_inprogress.xml" @@ -87,6 +88,17 @@ def test_wait_for_job_finished(self) -> None: self.assertEqual(job_id, job.id) self.assertListEqual(job.notes, ["Job detail notes"]) + def test_wait_for_job_completed(self) -> None: + # Waiting for a bridge (cloud) job completion + response_xml = read_xml_asset(GET_BY_ID_COMPLETED_XML) + job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" + with mocked_time(), requests_mock.mock() as m: + m.get(f"{self.baseurl}/{job_id}", text=response_xml) + job = self.server.jobs.wait_for_job(job_id) + + self.assertEqual(job_id, job.id) + self.assertListEqual(job.notes, ["Job detail notes"]) + def test_wait_for_job_failed(self) -> None: # Waiting for a failed job raises an exception response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) diff --git a/test/test_project.py b/test/test_project.py index 56787efac..c51f2e1e6 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -10,6 +10,7 @@ TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = asset("project_get.xml") +GET_XML_ALL_FIELDS = asset("project_get_all_fields.xml") UPDATE_XML = asset("project_update.xml") SET_CONTENT_PERMISSIONS_XML = asset("project_content_permission.xml") CREATE_XML = asset("project_create.xml") @@ -410,3 +411,28 @@ def test_delete_virtualconnection_default_permimssions(self): m.delete(f"{base_url}/{endpoint}/Connect/Allow", status_code=204) self.server.projects.delete_virtualconnection_default_permissions(project, rule) + + def test_get_all_fields(self) -> None: + self.server.version = "3.23" + base_url = self.server.projects.baseurl + with open(GET_XML_ALL_FIELDS, "rb") as f: + response_xml = f.read().decode("utf-8") + + ro = TSC.RequestOptions() + ro.all_fields = True + + with requests_mock.mock() as m: + m.get(f"{base_url}?fields=_all_", text=response_xml) + all_projects, pagination_item = self.server.projects.get(req_options=ro) + + assert pagination_item.total_available == 3 + assert len(all_projects) == 1 + project: TSC.ProjectItem = all_projects[0] + assert isinstance(project, TSC.ProjectItem) + assert project.id == "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + assert project.name == "Samples" + assert project.description == "This project includes automatically uploaded samples." + assert project.top_level_project is True + assert project.content_permissions == "ManagedByOwner" + assert project.parent_id is None + assert project.writeable is True diff --git a/test/test_request_option.py b/test/test_request_option.py index 7405189a3..57dfdc2a0 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -251,7 +251,7 @@ def test_all_fields(self) -> None: m.get(requests_mock.ANY) url = self.baseurl + "/views/456/data" opts = TSC.RequestOptions() - opts._all_fields = True + opts.all_fields = True resp = self.server.users.get_request(url, request_object=opts) self.assertTrue(re.search("fields=_all_", resp.request.query)) @@ -368,3 +368,13 @@ def test_language_export(self) -> None: resp = self.server.users.get_request(url, request_object=opts) self.assertTrue(re.search("language=en-us", resp.request.query)) + + def test_queryset_fields(self) -> None: + loop = self.server.users.fields("id") + assert "id" in loop.request_options.fields + assert "_default_" in loop.request_options.fields + + def test_queryset_only_fields(self) -> None: + loop = self.server.users.only_fields("id") + assert "id" in loop.request_options.fields + assert "_default_" not in loop.request_options.fields diff --git a/test/test_schedule.py b/test/test_schedule.py index b072522a4..4fcc85e18 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -25,6 +25,7 @@ ADD_WORKBOOK_TO_SCHEDULE_WITH_WARNINGS = os.path.join(TEST_ASSET_DIR, "schedule_add_workbook_with_warnings.xml") ADD_DATASOURCE_TO_SCHEDULE = os.path.join(TEST_ASSET_DIR, "schedule_add_datasource.xml") ADD_FLOW_TO_SCHEDULE = os.path.join(TEST_ASSET_DIR, "schedule_add_flow.xml") +GET_EXTRACT_TASKS_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_extract_refresh_tasks.xml") WORKBOOK_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id.xml") DATASOURCE_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "datasource_get_by_id.xml") @@ -405,3 +406,20 @@ def test_add_flow(self) -> None: flow = self.server.flows.get_by_id("bar") result = self.server.schedules.add_to_schedule("foo", flow=flow) self.assertEqual(0, len(result), "Added properly") + + def test_get_extract_refresh_tasks(self) -> None: + self.server.version = "2.3" + + with open(GET_EXTRACT_TASKS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules/{schedule_id}/extracts" + m.get(baseurl, text=response_xml) + + extracts = self.server.schedules.get_extract_refresh_tasks(schedule_id) + + self.assertIsNotNone(extracts) + self.assertIsInstance(extracts[0], list) + self.assertEqual(2, len(extracts[0])) + self.assertEqual("task1", extracts[0][0].id) diff --git a/test/test_site.py b/test/test_site.py index 96b75f9ff..243810254 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -13,6 +13,7 @@ GET_BY_NAME_XML = os.path.join(TEST_ASSET_DIR, "site_get_by_name.xml") UPDATE_XML = os.path.join(TEST_ASSET_DIR, "site_update.xml") CREATE_XML = os.path.join(TEST_ASSET_DIR, "site_create.xml") +SITE_AUTH_CONFIG_XML = os.path.join(TEST_ASSET_DIR, "site_auth_configurations.xml") class SiteTests(unittest.TestCase): @@ -260,3 +261,28 @@ def test_decrypt(self) -> None: with requests_mock.mock() as m: m.post(self.baseurl + "/0626857c-1def-4503-a7d8-7907c3ff9d9f/decrypt-extracts", status_code=200) self.server.sites.decrypt_extracts("0626857c-1def-4503-a7d8-7907c3ff9d9f") + + def test_list_auth_configurations(self) -> None: + self.server.version = "3.24" + self.baseurl = self.server.sites.baseurl + with open(SITE_AUTH_CONFIG_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + + assert self.baseurl == self.server.sites.baseurl + + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{self.server.site_id}/site-auth-configurations", status_code=200, text=response_xml) + configs = self.server.sites.list_auth_configurations() + + assert len(configs) == 2, "Expected 2 auth configurations" + + assert configs[0].auth_setting == "OIDC" + assert configs[0].enabled + assert configs[0].idp_configuration_id == "00000000-0000-0000-0000-000000000000" + assert configs[0].idp_configuration_name == "Initial Salesforce" + assert configs[0].known_provider_alias == "Salesforce" + assert configs[1].auth_setting == "SAML" + assert configs[1].enabled + assert configs[1].idp_configuration_id == "11111111-1111-1111-1111-111111111111" + assert configs[1].idp_configuration_name == "Initial SAML" + assert configs[1].known_provider_alias is None diff --git a/test/test_ssl_config.py b/test/test_ssl_config.py new file mode 100644 index 000000000..036a326ca --- /dev/null +++ b/test/test_ssl_config.py @@ -0,0 +1,77 @@ +import unittest +import ssl +from unittest.mock import patch, MagicMock +from tableauserverclient import Server +from tableauserverclient.server.endpoint import Endpoint +import logging + + +class TestSSLConfig(unittest.TestCase): + @patch("requests.session") + @patch("tableauserverclient.server.endpoint.Endpoint.set_parameters") + def setUp(self, mock_set_parameters, mock_session): + """Set up test fixtures with mocked session and request validation""" + # Mock the session + self.mock_session = MagicMock() + mock_session.return_value = self.mock_session + + # Mock request preparation + self.mock_request = MagicMock() + self.mock_session.prepare_request.return_value = self.mock_request + + # Create server instance with mocked components + self.server = Server("http://test") + + def test_default_ssl_config(self): + """Test that by default, no custom SSL context is used""" + self.assertIsNone(self.server._ssl_context) + self.assertNotIn("verify", self.server.http_options) + + @patch("ssl.create_default_context") + def test_weak_dh_config(self, mock_create_context): + """Test that weak DH keys can be allowed when configured""" + # Setup mock SSL context + mock_context = MagicMock() + mock_create_context.return_value = mock_context + + # Configure SSL with weak DH + self.server.configure_ssl(allow_weak_dh=True) + + # Verify SSL context was created and configured correctly + mock_create_context.assert_called_once() + mock_context.set_dh_parameters.assert_called_once_with(min_key_bits=512) + + # Verify context was added to http options + self.assertEqual(self.server.http_options["verify"], mock_context) + + @patch("ssl.create_default_context") + def test_disable_weak_dh_config(self, mock_create_context): + """Test that SSL config can be reset to defaults""" + # Setup mock SSL context + mock_context = MagicMock() + mock_create_context.return_value = mock_context + + # First enable weak DH + self.server.configure_ssl(allow_weak_dh=True) + self.assertIsNotNone(self.server._ssl_context) + self.assertIn("verify", self.server.http_options) + + # Then disable it + self.server.configure_ssl(allow_weak_dh=False) + self.assertIsNone(self.server._ssl_context) + self.assertNotIn("verify", self.server.http_options) + + @patch("ssl.create_default_context") + def test_warning_on_weak_dh(self, mock_create_context): + """Test that a warning is logged when enabling weak DH keys""" + logging.getLogger().setLevel(logging.WARNING) + with self.assertLogs(level="WARNING") as log: + self.server.configure_ssl(allow_weak_dh=True) + self.assertTrue( + any("WARNING: Allowing weak Diffie-Hellman keys" in record for record in log.output), + "Expected warning about weak DH keys was not logged", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_user.py b/test/test_user.py index a46624845..fa2ac3a12 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,14 +1,16 @@ import os import unittest +from defusedxml import ElementTree as ET import requests_mock import tableauserverclient as TSC -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = os.path.join(TEST_ASSET_DIR, "user_get.xml") +GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "user_get_all_fields.xml") GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "user_get_empty.xml") GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "user_get_by_id.xml") UPDATE_XML = os.path.join(TEST_ASSET_DIR, "user_update.xml") @@ -162,6 +164,22 @@ def test_populate_workbooks(self) -> None: self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", workbook_list[0].owner_id) self.assertEqual({"Safari", "Sample"}, workbook_list[0].tags) + def test_populate_owned_workbooks(self) -> None: + with open(POPULATE_WORKBOOKS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + # Query parameter ownedBy is case sensitive. + with requests_mock.mock(case_sensitive=True) as m: + m.get(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794/workbooks?ownedBy=true", text=response_xml) + single_user = TSC.UserItem("test", "Interactor") + single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + self.server.users.populate_workbooks(single_user, owned_only=True) + list(single_user.workbooks) + + request_history = m.request_history[0] + + assert "ownedBy" in request_history.qs, "ownedBy not in request history" + assert "true" in request_history.qs["ownedBy"], "ownedBy not set to true in request history" + def test_populate_workbooks_missing_id(self) -> None: single_user = TSC.UserItem("test", "Interactor") self.assertRaises(TSC.MissingRequiredFieldError, self.server.users.populate_workbooks, single_user) @@ -233,3 +251,72 @@ def test_get_users_from_file(self): users, failures = self.server.users.create_from_file(USERS) assert users[0].name == "Cassie", users assert failures == [] + + def test_get_users_all_fields(self) -> None: + self.server.version = "3.7" + baseurl = self.server.users.baseurl + with open(GET_XML_ALL_FIELDS) as f: + response_xml = f.read() + + with requests_mock.mock() as m: + m.get(f"{baseurl}?fields=_all_", text=response_xml) + all_users, _ = self.server.users.get() + + assert all_users[0].auth_setting == "TableauIDWithMFA" + assert all_users[0].email == "bob@example.com" + assert all_users[0].external_auth_user_id == "38c870c3ac5e84ec66e6ced9fb23681835b07e56d5660371ac1f705cc65bd610" + assert all_users[0].fullname == "Bob Smith" + assert all_users[0].id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert all_users[0].last_login == parse_datetime("2025-02-04T06:39:20Z") + assert all_users[0].name == "bob@example.com" + assert all_users[0].site_role == "SiteAdministratorCreator" + assert all_users[0].locale is None + assert all_users[0].language == "en" + assert all_users[0].idp_configuration_id == "22222222-2222-2222-2222-222222222222" + assert all_users[0].domain_name == "TABID_WITH_MFA" + assert all_users[1].auth_setting == "TableauIDWithMFA" + assert all_users[1].email == "alice@example.com" + assert all_users[1].external_auth_user_id == "96f66b893b22669cdfa632275d354cd1d92cea0266f3be7702151b9b8c52be29" + assert all_users[1].fullname == "Alice Jones" + assert all_users[1].id == "f6d72445-285b-48e5-8380-f90b519ce682" + assert all_users[1].name == "alice@example.com" + assert all_users[1].site_role == "ExplorerCanPublish" + assert all_users[1].locale is None + assert all_users[1].language == "en" + assert all_users[1].idp_configuration_id == "22222222-2222-2222-2222-222222222222" + assert all_users[1].domain_name == "TABID_WITH_MFA" + + def test_add_user_idp_configuration(self) -> None: + with open(ADD_XML) as f: + response_xml = f.read() + user = TSC.UserItem(name="Cassie", site_role="Viewer") + user.idp_configuration_id = "012345" + + with requests_mock.mock() as m: + m.post(self.server.users.baseurl, text=response_xml) + user = self.server.users.add(user) + + history = m.request_history[0] + + tree = ET.fromstring(history.text) + user_elem = tree.find(".//user") + assert user_elem is not None + assert user_elem.attrib["idpConfigurationId"] == "012345" + + def test_update_user_idp_configuration(self) -> None: + with open(ADD_XML) as f: + response_xml = f.read() + user = TSC.UserItem(name="Cassie", site_role="Viewer") + user._id = "0123456789" + user.idp_configuration_id = "012345" + + with requests_mock.mock() as m: + m.put(f"{self.server.users.baseurl}/{user.id}", text=response_xml) + user = self.server.users.update(user) + + history = m.request_history[0] + + tree = ET.fromstring(history.text) + user_elem = tree.find(".//user") + assert user_elem is not None + assert user_elem.attrib["idpConfigurationId"] == "012345" diff --git a/test/test_view.py b/test/test_view.py index a89a6d235..ee6d518de 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -5,12 +5,14 @@ import tableauserverclient as TSC from tableauserverclient import UserItem, GroupItem, PermissionsRule -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime +from tableauserverclient.server.endpoint.exceptions import UnsupportedAttributeError TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, "view_add_tags.xml") GET_XML = os.path.join(TEST_ASSET_DIR, "view_get.xml") +GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "view_get_all_fields.xml") GET_XML_ID = os.path.join(TEST_ASSET_DIR, "view_get_id.xml") GET_XML_USAGE = os.path.join(TEST_ASSET_DIR, "view_get_usage.xml") GET_XML_ID_USAGE = os.path.join(TEST_ASSET_DIR, "view_get_id_usage.xml") @@ -177,6 +179,43 @@ def test_populate_image(self) -> None: self.server.views.populate_image(single_view) self.assertEqual(response, single_view.image) + def test_populate_image_unsupported(self) -> None: + self.server.version = "3.8" + 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?vizWidth=1920&vizHeight=1080", + content=response, + ) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + req_option = TSC.ImageRequestOptions(viz_width=1920, viz_height=1080) + + with self.assertRaises(UnsupportedAttributeError): + self.server.views.populate_image(single_view, req_option) + + def test_populate_image_viz_dimensions(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.views.baseurl + 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?vizWidth=1920&vizHeight=1080", + content=response, + ) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + req_option = TSC.ImageRequestOptions(viz_width=1920, viz_height=1080) + + self.server.views.populate_image(single_view, req_option) + self.assertEqual(response, single_view.image) + + history = m.request_history + def test_populate_image_with_options(self) -> None: with open(POPULATE_PREVIEW_IMAGE, "rb") as f: response = f.read() @@ -364,3 +403,116 @@ def test_pdf_errors(self) -> None: req_option = TSC.PDFRequestOptions(viz_width=1920) with self.assertRaises(ValueError): req_option.get_query_params() + + def test_view_get_all_fields(self) -> None: + self.server.version = "3.21" + self.baseurl = self.server.views.baseurl + with open(GET_XML_ALL_FIELDS) as f: + response_xml = f.read() + + ro = TSC.RequestOptions() + ro.all_fields = True + + with requests_mock.mock() as m: + m.get(f"{self.baseurl}?fields=_all_", text=response_xml) + views, _ = self.server.views.get(req_options=ro) + + assert views[0].id == "2bdcd787-dcc6-4a5d-bc61-2846f1ef4534" + assert views[0].name == "Overview" + assert views[0].content_url == "Superstore/sheets/Overview" + assert views[0].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[0].updated_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[0].sheet_type == "dashboard" + assert views[0].favorites_total == 0 + assert views[0].view_url_name == "Overview" + assert isinstance(views[0].workbook, TSC.WorkbookItem) + assert views[0].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert views[0].workbook.name == "Superstore" + assert views[0].workbook.content_url == "Superstore" + assert views[0].workbook.show_tabs + assert views[0].workbook.size == 2 + assert views[0].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[0].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert views[0].workbook.sheet_count == 9 + assert not views[0].workbook.has_extracts + assert isinstance(views[0].owner, TSC.UserItem) + assert views[0].owner.email == "bob@example.com" + assert views[0].owner.fullname == "Bob" + assert views[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert views[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert views[0].owner.name == "bob@example.com" + assert views[0].owner.site_role == "SiteAdministratorCreator" + assert isinstance(views[0].project, TSC.ProjectItem) + assert views[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[0].project.name == "Samples" + assert views[0].project.description == "This project includes automatically uploaded samples." + assert views[0].total_views == 0 + assert isinstance(views[0].location, TSC.LocationItem) + assert views[0].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[0].location.type == "Project" + assert views[1].id == "2a3fd19d-9129-413d-9ff7-9dfc36bf7f7e" + assert views[1].name == "Product" + assert views[1].content_url == "Superstore/sheets/Product" + assert views[1].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[1].updated_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[1].sheet_type == "dashboard" + assert views[1].favorites_total == 0 + assert views[1].view_url_name == "Product" + assert isinstance(views[1].workbook, TSC.WorkbookItem) + assert views[1].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert views[1].workbook.name == "Superstore" + assert views[1].workbook.content_url == "Superstore" + assert views[1].workbook.show_tabs + assert views[1].workbook.size == 2 + assert views[1].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[1].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert views[1].workbook.sheet_count == 9 + assert not views[1].workbook.has_extracts + assert isinstance(views[1].owner, TSC.UserItem) + assert views[1].owner.email == "bob@example.com" + assert views[1].owner.fullname == "Bob" + assert views[1].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert views[1].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert views[1].owner.name == "bob@example.com" + assert views[1].owner.site_role == "SiteAdministratorCreator" + assert isinstance(views[1].project, TSC.ProjectItem) + assert views[1].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[1].project.name == "Samples" + assert views[1].project.description == "This project includes automatically uploaded samples." + assert views[1].total_views == 0 + assert isinstance(views[1].location, TSC.LocationItem) + assert views[1].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[1].location.type == "Project" + assert views[2].id == "459eda9a-85e4-46bf-a2f2-62936bd2e99a" + assert views[2].name == "Customers" + assert views[2].content_url == "Superstore/sheets/Customers" + assert views[2].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[2].updated_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[2].sheet_type == "dashboard" + assert views[2].favorites_total == 0 + assert views[2].view_url_name == "Customers" + assert isinstance(views[2].workbook, TSC.WorkbookItem) + assert views[2].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert views[2].workbook.name == "Superstore" + assert views[2].workbook.content_url == "Superstore" + assert views[2].workbook.show_tabs + assert views[2].workbook.size == 2 + assert views[2].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[2].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert views[2].workbook.sheet_count == 9 + assert not views[2].workbook.has_extracts + assert isinstance(views[2].owner, TSC.UserItem) + assert views[2].owner.email == "bob@example.com" + assert views[2].owner.fullname == "Bob" + assert views[2].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert views[2].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert views[2].owner.name == "bob@example.com" + assert views[2].owner.site_role == "SiteAdministratorCreator" + assert isinstance(views[2].project, TSC.ProjectItem) + assert views[2].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[2].project.name == "Samples" + assert views[2].project.description == "This project includes automatically uploaded samples." + assert views[2].total_views == 0 + assert isinstance(views[2].location, TSC.LocationItem) + assert views[2].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[2].location.type == "Project" diff --git a/test/test_virtual_connection.py b/test/test_virtual_connection.py index 975033d2d..5d9a2d1bc 100644 --- a/test/test_virtual_connection.py +++ b/test/test_virtual_connection.py @@ -2,6 +2,7 @@ from pathlib import Path import unittest +import pytest import requests_mock import tableauserverclient as TSC @@ -12,6 +13,7 @@ VIRTUAL_CONNECTION_GET_XML = ASSET_DIR / "virtual_connections_get.xml" VIRTUAL_CONNECTION_POPULATE_CONNECTIONS = ASSET_DIR / "virtual_connection_populate_connections.xml" +VIRTUAL_CONNECTION_POPULATE_CONNECTIONS2 = ASSET_DIR / "virtual_connection_populate_connections2.xml" VC_DB_CONN_UPDATE = ASSET_DIR / "virtual_connection_database_connection_update.xml" VIRTUAL_CONNECTION_DOWNLOAD = ASSET_DIR / "virtual_connections_download.xml" VIRTUAL_CONNECTION_UPDATE = ASSET_DIR / "virtual_connections_update.xml" @@ -54,23 +56,27 @@ def test_virtual_connection_get(self): assert items[0].name == "vconn" def test_virtual_connection_populate_connections(self): - vconn = VirtualConnectionItem("vconn") - vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{vconn.id}/connections", text=VIRTUAL_CONNECTION_POPULATE_CONNECTIONS.read_text()) - vc_out = self.server.virtual_connections.populate_connections(vconn) - connection_list = list(vconn.connections) - - assert vc_out is vconn - assert vc_out._connections is not None - - assert len(connection_list) == 1 - connection = connection_list[0] - assert connection.id == "37ca6ced-58d7-4dcf-99dc-f0a85223cbef" - assert connection.connection_type == "postgres" - assert connection.server_address == "localhost" - assert connection.server_port == "5432" - assert connection.username == "pgadmin" + for i, populate_connections_xml in enumerate( + (VIRTUAL_CONNECTION_POPULATE_CONNECTIONS, VIRTUAL_CONNECTION_POPULATE_CONNECTIONS2) + ): + with self.subTest(i): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{vconn.id}/connections", text=populate_connections_xml.read_text()) + vc_out = self.server.virtual_connections.populate_connections(vconn) + connection_list = list(vconn.connections) + + assert vc_out is vconn + assert vc_out._connections is not None + + assert len(connection_list) == 1 + connection = connection_list[0] + assert connection.id == "37ca6ced-58d7-4dcf-99dc-f0a85223cbef" + assert connection.connection_type == "postgres" + assert connection.server_address == "localhost" + assert connection.server_port == "5432" + assert connection.username == "pgadmin" def test_virtual_connection_update_connection_db_connection(self): vconn = VirtualConnectionItem("vconn") diff --git a/test/test_workbook.py b/test/test_workbook.py index 0aa52f50d..84afd7fcb 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -10,9 +10,9 @@ import pytest import tableauserverclient as TSC -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime from tableauserverclient.models import UserItem, GroupItem, PermissionsRule -from tableauserverclient.server.endpoint.exceptions import InternalServerError +from tableauserverclient.server.endpoint.exceptions import InternalServerError, UnsupportedAttributeError from tableauserverclient.server.request_factory import RequestFactory from ._utils import asset @@ -24,6 +24,7 @@ GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_empty.xml") GET_INVALID_DATE_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_invalid_date.xml") GET_XML = os.path.join(TEST_ASSET_DIR, "workbook_get.xml") +GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "workbook_get_all_fields.xml") ODATA_XML = os.path.join(TEST_ASSET_DIR, "odata_connection.xml") POPULATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_connections.xml") POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf") @@ -450,6 +451,49 @@ def test_populate_pdf(self) -> None: self.server.workbooks.populate_pdf(single_workbook, req_option) self.assertEqual(response, single_workbook.pdf) + def test_populate_pdf_unsupported(self) -> None: + self.server.version = "3.4" + self.baseurl = self.server.workbooks.baseurl + with requests_mock.mock() as m: + m.get( + self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/pdf?type=a5&orientation=landscape", + content=b"", + ) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + + type = TSC.PDFRequestOptions.PageType.A5 + orientation = TSC.PDFRequestOptions.Orientation.Landscape + req_option = TSC.PDFRequestOptions(type, orientation) + req_option.vf("Region", "West") + + with self.assertRaises(UnsupportedAttributeError): + self.server.workbooks.populate_pdf(single_workbook, req_option) + + def test_populate_pdf_vf_dims(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.workbooks.baseurl + with open(POPULATE_PDF, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get( + self.baseurl + + "/1f951daf-4061-451a-9df1-69a8062664f2/pdf?type=a5&orientation=landscape&vf_Region=West&vizWidth=1920&vizHeight=1080", + content=response, + ) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + + type = TSC.PDFRequestOptions.PageType.A5 + orientation = TSC.PDFRequestOptions.Orientation.Landscape + req_option = TSC.PDFRequestOptions(type, orientation) + req_option.vf("Region", "West") + req_option.viz_width = 1920 + req_option.viz_height = 1080 + + self.server.workbooks.populate_pdf(single_workbook, req_option) + self.assertEqual(response, single_workbook.pdf) + def test_populate_powerpoint(self) -> None: self.server.version = "3.8" self.baseurl = self.server.workbooks.baseurl @@ -457,13 +501,15 @@ def test_populate_powerpoint(self) -> None: response = f.read() with requests_mock.mock() as m: m.get( - self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/powerpoint", + self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/powerpoint?maxAge=1", content=response, ) single_workbook = TSC.WorkbookItem("test") single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - self.server.workbooks.populate_powerpoint(single_workbook) + ro = TSC.PPTXRequestOptions(maxage=1) + + self.server.workbooks.populate_powerpoint(single_workbook, ro) self.assertEqual(response, single_workbook.powerpoint) def test_populate_preview_image(self) -> None: @@ -933,3 +979,106 @@ def test_odata_connection(self) -> None: assert xml_connection is not None self.assertEqual(xml_connection.get("serverAddress"), url) + + def test_get_workbook_all_fields(self) -> None: + self.server.version = "3.21" + baseurl = self.server.workbooks.baseurl + + with open(GET_XML_ALL_FIELDS) as f: + response = f.read() + + ro = TSC.RequestOptions() + ro.all_fields = True + + with requests_mock.mock() as m: + m.get(f"{baseurl}?fields=_all_", text=response) + workbooks, _ = self.server.workbooks.get(req_options=ro) + + assert workbooks[0].id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert workbooks[0].name == "Superstore" + assert workbooks[0].content_url == "Superstore" + assert workbooks[0].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265605" + assert workbooks[0].show_tabs + assert workbooks[0].size == 2 + assert workbooks[0].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert workbooks[0].updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert workbooks[0].sheet_count == 9 + assert not workbooks[0].has_extracts + assert not workbooks[0].encrypt_extracts + assert workbooks[0].default_view_id == "2bdcd787-dcc6-4a5d-bc61-2846f1ef4534" + assert workbooks[0].share_description == "Superstore" + assert workbooks[0].last_published_at == parse_datetime("2024-02-14T04:42:09Z") + assert isinstance(workbooks[0].project, TSC.ProjectItem) + assert workbooks[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[0].project.name == "Samples" + assert workbooks[0].project.description == "This project includes automatically uploaded samples." + assert isinstance(workbooks[0].location, TSC.LocationItem) + assert workbooks[0].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[0].location.type == "Project" + assert workbooks[0].location.name == "Samples" + assert isinstance(workbooks[0].owner, TSC.UserItem) + assert workbooks[0].owner.email == "bob@example.com" + assert workbooks[0].owner.fullname == "Bob Smith" + assert workbooks[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert workbooks[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert workbooks[0].owner.name == "bob@example.com" + assert workbooks[0].owner.site_role == "SiteAdministratorCreator" + assert workbooks[1].id == "6693cb26-9507-4174-ad3e-9de81a18c971" + assert workbooks[1].name == "World Indicators" + assert workbooks[1].content_url == "WorldIndicators" + assert workbooks[1].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265606" + assert workbooks[1].show_tabs + assert workbooks[1].size == 1 + assert workbooks[1].created_at == parse_datetime("2024-02-14T04:42:11Z") + assert workbooks[1].updated_at == parse_datetime("2024-02-14T04:42:12Z") + assert workbooks[1].sheet_count == 8 + assert not workbooks[1].has_extracts + assert not workbooks[1].encrypt_extracts + assert workbooks[1].default_view_id == "3d10dbcf-a206-47c7-91ba-ebab3ab33d7c" + assert workbooks[1].share_description == "World Indicators" + assert workbooks[1].last_published_at == parse_datetime("2024-02-14T04:42:11Z") + assert isinstance(workbooks[1].project, TSC.ProjectItem) + assert workbooks[1].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[1].project.name == "Samples" + assert workbooks[1].project.description == "This project includes automatically uploaded samples." + assert isinstance(workbooks[1].location, TSC.LocationItem) + assert workbooks[1].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[1].location.type == "Project" + assert workbooks[1].location.name == "Samples" + assert isinstance(workbooks[1].owner, TSC.UserItem) + assert workbooks[1].owner.email == "bob@example.com" + assert workbooks[1].owner.fullname == "Bob Smith" + assert workbooks[1].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert workbooks[1].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert workbooks[1].owner.name == "bob@example.com" + assert workbooks[1].owner.site_role == "SiteAdministratorCreator" + assert workbooks[2].id == "dbc0f162-909f-4edf-8392-0d12a80af955" + assert workbooks[2].name == "Superstore" + assert workbooks[2].description == "This is a superstore workbook" + assert workbooks[2].content_url == "Superstore_17078880698360" + assert workbooks[2].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265621" + assert not workbooks[2].show_tabs + assert workbooks[2].size == 1 + assert workbooks[2].created_at == parse_datetime("2024-02-14T05:21:09Z") + assert workbooks[2].updated_at == parse_datetime("2024-07-02T02:19:59Z") + assert workbooks[2].sheet_count == 7 + assert workbooks[2].has_extracts + assert not workbooks[2].encrypt_extracts + assert workbooks[2].default_view_id == "8c4b1d3e-3f31-4d2a-8b9f-492b92f27987" + assert workbooks[2].share_description == "Superstore" + assert workbooks[2].last_published_at == parse_datetime("2024-07-02T02:19:58Z") + assert isinstance(workbooks[2].project, TSC.ProjectItem) + assert workbooks[2].project.id == "9836791c-9468-40f0-b7f3-d10b9562a046" + assert workbooks[2].project.name == "default" + assert workbooks[2].project.description == "The default project that was automatically created by Tableau." + assert isinstance(workbooks[2].location, TSC.LocationItem) + assert workbooks[2].location.id == "9836791c-9468-40f0-b7f3-d10b9562a046" + assert workbooks[2].location.type == "Project" + assert workbooks[2].location.name == "default" + assert isinstance(workbooks[2].owner, TSC.UserItem) + assert workbooks[2].owner.email == "bob@example.com" + assert workbooks[2].owner.fullname == "Bob Smith" + assert workbooks[2].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert workbooks[2].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert workbooks[2].owner.name == "bob@example.com" + assert workbooks[2].owner.site_role == "SiteAdministratorCreator"