diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml index 41a944e6..0e2b425e 100644 --- a/.github/workflows/meta-checks.yml +++ b/.github/workflows/meta-checks.yml @@ -13,6 +13,20 @@ jobs: runs-on: ${{ matrix.os }} steps: + - name: Get pip cache dir + id: pip-cache + shell: bash + run: | + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT + + - name: cache + uses: actions/cache@v4 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.python-version }}-pip- + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 7e1533ee..2e197cf2 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,11 +13,25 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13-dev'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] runs-on: ${{ matrix.os }} steps: + - name: Get pip cache dir + id: pip-cache + shell: bash + run: | + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT + + - name: cache + uses: actions/cache@v4 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.python-version }}-pip- + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} diff --git a/pyproject.toml b/pyproject.toml index c3cb67ed..08f90c49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ readme = "README.md" dependencies = [ 'defusedxml>=0.7.1', # latest as at 7/31/23 'packaging>=23.1', # latest as at 7/31/23 - 'requests>=2.31', # latest as at 7/31/23 + 'requests>=2.32', # latest as at 7/31/23 'urllib3>=2.2.2,<3', 'typing_extensions>=4.0.1', ] @@ -43,13 +43,13 @@ target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] check_untyped_defs = false disable_error_code = [ 'misc', - # tableauserverclient\server\endpoint\datasources_endpoint.py:48: error: Cannot assign multiple types to name "FilePath" without an explicit "Type[...]" annotation [misc] 'annotation-unchecked' # can be removed when check_untyped_defs = true ] files = ["tableauserverclient", "test", "samples"] show_error_codes = true ignore_missing_imports = true # defusedxml library has no types no_implicit_reexport = true +implicit_optional = true [tool.pytest.ini_options] testpaths = ["test"] diff --git a/samples/export.py b/samples/export.py index 815ec8b5..b2506cf4 100644 --- a/samples/export.py +++ b/samples/export.py @@ -37,8 +37,11 @@ def main(): "--csv", dest="type", action="store_const", const=("populate_csv", "CSVRequestOptions", "csv", "csv") ) # other options shown in explore_workbooks: workbook.download, workbook.preview_image - + parser.add_argument( + "--language", help="Text such as 'Average' will appear in this language. Use values like fr, de, es, en" + ) parser.add_argument("--workbook", action="store_true") + parser.add_argument("--custom_view", action="store_true") parser.add_argument("--file", "-f", help="filename to store the exported data") parser.add_argument("--filter", "-vf", metavar="COLUMN:VALUE", help="View filter to apply to the view") @@ -56,6 +59,8 @@ def main(): print("Connected") if args.workbook: item = server.workbooks.get_by_id(args.resource_id) + elif args.custom_view: + item = server.custom_views.get_by_id(args.resource_id) else: item = server.views.get_by_id(args.resource_id) @@ -72,18 +77,22 @@ def main(): populate = getattr(server.views, populate_func_name) if args.workbook: populate = getattr(server.workbooks, populate_func_name) + elif args.custom_view: + populate = getattr(server.custom_views, populate_func_name) option_factory = getattr(TSC, option_factory_name) + options: TSC.PDFRequestOptions = option_factory() if args.filter: - options = option_factory().vf(*args.filter.split(":")) - else: - options = None + options = options.vf(*args.filter.split(":")) + + if args.language: + options.language = args.language if args.file: filename = args.file else: - filename = f"out.{extension}" + filename = f"out-{options.language}.{extension}" populate(item, options) with open(filename, "wb") as f: diff --git a/samples/extracts.py b/samples/extracts.py index d21bfdd0..c0dd885b 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -1,13 +1,7 @@ #### -# This script demonstrates how to use the Tableau Server Client -# to interact with workbooks. It explores the different -# functions that the Server API supports on workbooks. -# -# With no flags set, this sample will query all workbooks, -# pick one workbook and populate its connections/views, and update -# the workbook. Adding flags will demonstrate the specific feature -# on top of the general operations. -#### +# This script demonstrates how to use the Tableau Server Client to interact with extracts. +# It explores the different functions that the REST API supports on extracts. +##### import argparse import logging diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index d967659a..1694bf0f 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -47,7 +47,7 @@ def main(): logging.basicConfig(level=logging_level) tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=True) + server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False}) with server.auth.sign_in(tableau_auth): group_name = "SALES NORTHWEST" # Try to create a group named "SALES NORTHWEST" @@ -57,37 +57,36 @@ def main(): # Try to create a group named "SALES ROMANIA" create_example_group(group_name, server) - # URL Encode the name of the group that we want to filter on - # i.e. turn spaces into plus signs - filter_group_name = urllib.parse.quote_plus(group_name) + # we no longer need to encode the space options = TSC.RequestOptions() - options.filter.add( - TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, filter_group_name) - ) + options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, group_name)) filtered_groups, _ = server.groups.get(req_options=options) # Result can either be a matching group or an empty list if filtered_groups: - group_name = filtered_groups.pop().name - print(group_name) + group = filtered_groups.pop() + print(group) else: - error = f"No project named '{filter_group_name}' found" + error = f"No group named '{group_name}' found" print(error) + print("---") + # Or, try the above with the django style filtering try: - group = server.groups.filter(name=filter_group_name)[0] + group = server.groups.filter(name=group_name)[0] + print(group) except IndexError: - print(f"No project named '{filter_group_name}' found") - else: - print(group.name) + print(f"No group named '{group_name}' found") + + print("====") options = TSC.RequestOptions() options.filter.add( TSC.Filter( TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, - ["SALES+NORTHWEST", "SALES+ROMANIA", "this_group"], + ["SALES NORTHWEST", "SALES ROMANIA", "this_group"], ) ) @@ -98,13 +97,20 @@ def main(): for group in matching_groups: print(group.name) + print("----") # or, try the above with the django style filtering. - - groups = ["SALES NORTHWEST", "SALES ROMANIA", "this_group"] - groups = [urllib.parse.quote_plus(group) for group in groups] - for group in server.groups.filter(name__in=groups).sort("-name"): + all_g = server.groups.all() + print(f"Searching locally among {all_g.total_available} groups") + for a in all_g: + print(a) + groups = [urllib.parse.quote_plus(group) for group in ["SALES NORTHWEST", "SALES ROMANIA", "this_group"]] + print(groups) + + for group in server.groups.filter(name__in=groups).order_by("-name"): print(group.name) + print("done") + if __name__ == "__main__": main() diff --git a/samples/login.py b/samples/login.py index 847d3558..bc99385b 100644 --- a/samples/login.py +++ b/samples/login.py @@ -7,9 +7,15 @@ import argparse import getpass import logging +import os import tableauserverclient as TSC -import env + + +def get_env(key): + if key in os.environ: + return os.environ[key] + return None # If a sample has additional arguments, then it should copy this code and insert them after the call to @@ -20,13 +26,13 @@ def set_up_and_log_in(): sample_define_common_options(parser) args = parser.parse_args() if not args.server: - args.server = env.server + args.server = get_env("SERVER") if not args.site: - args.site = env.site + args.site = get_env("SITE") if not args.token_name: - args.token_name = env.token_name + args.token_name = get_env("TOKEN_NAME") if not args.token_value: - args.token_value = env.token_value + args.token_value = get_env("TOKEN_VALUE") args.logging_level = "debug" server = sample_connect_to_server(args) @@ -79,10 +85,7 @@ def sample_connect_to_server(args): # Make sure we use an updated version of the rest apis, and pass in our cert handling choice server = TSC.Server(args.server, use_server_version=True, http_options={"verify": check_ssl_certificate}) server.auth.sign_in(tableau_auth) - server.version = "2.6" - new_site: TSC.SiteItem = TSC.SiteItem("cdnear", content_url=env.site) - server.auth.switch_site(new_site) - print("Logged in successfully") + server.version = "3.19" return server diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index 85f63fb3..c674e688 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -21,12 +21,17 @@ import argparse import logging +import os import tableauserverclient as TSC - -import env import tableauserverclient.datetime_helpers +def get_env(key): + if key in os.environ: + return os.environ[key] + return None + + def main(): parser = argparse.ArgumentParser(description="Publish a datasource to server.") # Common options; please keep those in sync across all samples @@ -52,13 +57,13 @@ def main(): args = parser.parse_args() if not args.server: - args.server = env.server + args.server = get_env("SERVER") if not args.site: - args.site = env.site + args.site = get_env("SITE") if not args.token_name: - args.token_name = env.token_name + args.token_name = get_env("TOKEN_NAME") if not args.token_value: - args.token_value = env.token_value + args.token_value = get_env("TOKEN_VALUE") args.logging = "debug" args.file = "C:/dev/tab-samples/5M.tdsx" args.async_ = True @@ -118,8 +123,10 @@ def main(): new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds ) print( - "{}Datasource published. Datasource ID: {}".format( - new_datasource.id, tableauserverclient.datetime_helpers.timestamp() + ( + "{}Datasource published. Datasource ID: {}".format( + new_datasource.id, tableauserverclient.datetime_helpers.timestamp() + ) ) ) print("\t\tClosing connection") diff --git a/samples/set_refresh_schedule.py b/samples/set_refresh_schedule.py index 56fd12e6..153bb0ee 100644 --- a/samples/set_refresh_schedule.py +++ b/samples/set_refresh_schedule.py @@ -38,7 +38,7 @@ def usage(args): def make_filter(**kwargs): options = TSC.RequestOptions() - for item, value in kwargs.items(): + for item, value in list(kwargs.items()): name = getattr(TSC.RequestOptions.Field, item) options.filter.add(TSC.Filter(name, TSC.RequestOptions.Operator.Equals, value)) return options diff --git a/samples/subscriptions.py b/samples/subscriptions.py new file mode 100644 index 00000000..7b50beb7 --- /dev/null +++ b/samples/subscriptions.py @@ -0,0 +1,83 @@ +#### +# This script demonstrates how to create a subscription on a schedule +# +# To run the script, you must have installed Python 3.7 or later. +#### + + +import argparse +import logging + +import tableauserverclient as TSC + + +def usage(args): + parser = argparse.ArgumentParser(description="Set refresh schedule for a workbook or datasource.") + # Common options; please keep those in sync across all samples + parser.add_argument("--server", "-s", help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) + # Options specific to this sample + parser.add_argument("schedule", help="The *name* of a schedule to use") + + return parser.parse_args(args) + + +def create_subscription(schedule: TSC.ScheduleItem, user: TSC.UserItem, target: TSC.Target): + # Create the new SubscriptionItem object with variables from above. + new_sub = TSC.SubscriptionItem("My scheduled subscription", schedule.id, user.id, target) + + # (Optional) Set other fields. Any of these can be added or removed. + new_sub.attach_image = True + new_sub.attach_pdf = True + new_sub.message = "An update from Tableau!" + new_sub.page_orientation = TSC.PDFRequestOptions.Orientation.Portrait + new_sub.page_size_option = TSC.PDFRequestOptions.PageType.A4 + new_sub.send_if_view_empty = True + + return new_sub + + +def get_schedule_by_name(server, name): + schedules = [x for x in TSC.Pager(server.schedules) if x.name == name] + assert len(schedules) == 1 + return schedules.pop() + + +def run(args): + # Set logging level based on user input, or error by default + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + # Step 1: Sign in to server. + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False}) + with server.auth.sign_in(tableau_auth): + target = TSC.Target(server.workbooks.get()[0][0].id, "workbook") + print(target) + schedule = get_schedule_by_name(server, args.schedule) + user = server.users.get_by_id(server.user_id) + sub_values = create_subscription(schedule, user, target) + + # Create the new subscription on the site you are logged in. + sub = server.subscriptions.create(sub_values) + print(sub) + + +def main(): + import sys + + args = usage(sys.argv[1:]) + run(args) + + +if __name__ == "__main__": + main() diff --git a/samples/update_connection.py b/samples/update_connection.py index 4af6592b..0fe2f342 100644 --- a/samples/update_connection.py +++ b/samples/update_connection.py @@ -45,7 +45,7 @@ def main(): update_function = endpoint.update_connection resource = endpoint.get_by_id(args.resource_id) endpoint.populate_connections(resource) - connections = list(filter(lambda x: x.id == args.connection_id, resource.connections)) + connections = list([x for x in resource.connections if x.id == args.connection_id]) assert len(connections) == 1 connection = connections[0] connection.username = args.datasource_username diff --git a/samples/update_workbook_data_acceleration.py b/samples/update_workbook_data_acceleration.py deleted file mode 100644 index 57a1363e..00000000 --- a/samples/update_workbook_data_acceleration.py +++ /dev/null @@ -1,109 +0,0 @@ -#### -# This script demonstrates how to update workbook data acceleration using the Tableau -# Server Client. -# -# To run the script, you must have installed Python 3.7 or later. -#### - - -import argparse -import logging - -import tableauserverclient as TSC -from tableauserverclient import IntervalItem - - -def main(): - parser = argparse.ArgumentParser(description="Creates sample schedules for each type of frequency.") - # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", help="server address") - parser.add_argument("--site", "-S", help="site name") - parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") - parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") - parser.add_argument( - "--logging-level", - "-l", - choices=["debug", "info", "error"], - default="error", - help="desired logging level (set to error by default)", - ) - # Options specific to this sample: - # This sample has no additional options, yet. If you add some, please add them here - - args = parser.parse_args() - - # Set logging level based on user input, or error by default - logging_level = getattr(logging, args.logging_level.upper()) - logging.basicConfig(level=logging_level) - - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=False) - server.add_http_options({"verify": False}) - server.use_server_version() - with server.auth.sign_in(tableau_auth): - # Get workbook - all_workbooks, pagination_item = server.workbooks.get() - print(f"\nThere are {pagination_item.total_available} workbooks on site: ") - print([workbook.name for workbook in all_workbooks]) - - if all_workbooks: - # Pick 1 workbook to try data acceleration. - # Note that data acceleration has a couple of requirements, please check the Tableau help page - # to verify your workbook/view is eligible for data acceleration. - - # Assuming 1st workbook is eligible for sample purposes - sample_workbook = all_workbooks[2] - - # Enable acceleration for all the views in the workbook - enable_config = dict() - enable_config["acceleration_enabled"] = True - enable_config["accelerate_now"] = True - - sample_workbook.data_acceleration_config = enable_config - updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) - # Since we did not set any specific view, we will enable all views in the workbook - print("Enable acceleration for all the views in the workbook " + updated.name + ".") - - # Disable acceleration on one of the view in the workbook - # You have to populate_views first, then set the views of the workbook - # to the ones you want to update. - server.workbooks.populate_views(sample_workbook) - view_to_disable = sample_workbook.views[0] - sample_workbook.views = [view_to_disable] - - disable_config = dict() - disable_config["acceleration_enabled"] = False - disable_config["accelerate_now"] = True - - sample_workbook.data_acceleration_config = disable_config - # To get the acceleration status on the response, set includeViewAccelerationStatus=true - # Note that you have to populate_views first to get the acceleration status, since - # acceleration status is per view basis (not per workbook) - updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook, True) - view1 = updated.views[0] - print('Disabled acceleration for 1 view "' + view1.name + '" in the workbook ' + updated.name + ".") - - # Get acceleration status of the views in workbook using workbooks.get_by_id - # This won't need to do populate_views beforehand - my_workbook = server.workbooks.get_by_id(sample_workbook.id) - view1 = my_workbook.views[0] - view2 = my_workbook.views[1] - print( - "Fetching acceleration status for views in the workbook " - + updated.name - + ".\n" - + 'View "' - + view1.name - + '" has acceleration_status = ' - + view1.data_acceleration_config["acceleration_status"] - + ".\n" - + 'View "' - + view2.name - + '" has acceleration_status = ' - + view2.data_acceleration_config["acceleration_status"] - + "." - ) - - -if __name__ == "__main__": - main() diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index bab2cf05..e0a7abb6 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -32,11 +32,13 @@ PermissionsRule, PersonalAccessTokenAuth, ProjectItem, + Resource, RevisionItem, ScheduleItem, SiteItem, ServerInfoItem, SubscriptionItem, + TableauItem, TableItem, TableauAuth, Target, @@ -56,6 +58,7 @@ PDFRequestOptions, RequestOptions, MissingRequiredFieldError, + FailedSignInError, NotSignedInError, ServerResponseError, Filter, @@ -65,65 +68,68 @@ ) __all__ = [ - "get_versions", - "DEFAULT_NAMESPACE", "BackgroundJobItem", "BackgroundJobItem", "ColumnItem", "ConnectionCredentials", "ConnectionItem", + "CSVRequestOptions", "CustomViewItem", - "DQWItem", "DailyInterval", "DataAlertItem", "DatabaseItem", "DataFreshnessPolicyItem", "DatasourceItem", + "DEFAULT_NAMESPACE", + "DQWItem", + "ExcelRequestOptions", + "FailedSignInError", "FavoriteItem", + "FileuploadItem", + "Filter", "FlowItem", "FlowRunItem", - "FileuploadItem", + "get_versions", "GroupItem", "GroupSetItem", "HourlyInterval", + "ImageRequestOptions", "IntervalItem", "JobItem", "JWTAuth", + "LinkedTaskFlowRunItem", + "LinkedTaskItem", + "LinkedTaskStepItem", "MetricItem", + "MissingRequiredFieldError", "MonthlyInterval", + "NotSignedInError", + "Pager", "PaginationItem", + "PDFRequestOptions", "Permission", "PermissionsRule", "PersonalAccessTokenAuth", "ProjectItem", + "RequestOptions", + "Resource", "RevisionItem", "ScheduleItem", - "SiteItem", + "Server", "ServerInfoItem", + "ServerResponseError", + "SiteItem", + "Sort", "SubscriptionItem", - "TableItem", "TableauAuth", + "TableauItem", + "TableItem", "Target", "TaskItem", "UserItem", "ViewItem", + "VirtualConnectionItem", "WebhookItem", "WeeklyInterval", "WorkbookItem", - "CSVRequestOptions", - "ExcelRequestOptions", - "ImageRequestOptions", - "PDFRequestOptions", - "RequestOptions", - "MissingRequiredFieldError", - "NotSignedInError", - "ServerResponseError", - "Filter", - "Pager", - "Server", - "Sort", - "LinkedTaskItem", - "LinkedTaskStepItem", - "LinkedTaskFlowRunItem", - "VirtualConnectionItem", ] diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py index 63872398..a7511275 100644 --- a/tableauserverclient/config.py +++ b/tableauserverclient/config.py @@ -6,11 +6,13 @@ DELAY_SLEEP_SECONDS = 0.1 -# The maximum size of a file that can be published in a single request is 64MB -FILESIZE_LIMIT_MB = 64 - class Config: + # The maximum size of a file that can be published in a single request is 64MB + @property + def FILESIZE_LIMIT_MB(self): + return min(int(os.getenv("TSC_FILESIZE_LIMIT_MB", 64)), 64) + # For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks @property def CHUNK_SIZE_MB(self): diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py index de917bf4..a0c0a984 100644 --- a/tableauserverclient/models/custom_view_item.py +++ b/tableauserverclient/models/custom_view_item.py @@ -3,6 +3,7 @@ from defusedxml import ElementTree from defusedxml.ElementTree import fromstring, tostring from typing import Callable, Optional +from collections.abc import Iterator from .exceptions import UnpopulatedPropertyError from .user_item import UserItem @@ -17,6 +18,8 @@ def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None self._created_at: Optional["datetime"] = None self._id: Optional[str] = id self._image: Optional[Callable[[], bytes]] = None + self._pdf: Optional[Callable[[], bytes]] = None + self._csv: Optional[Callable[[], Iterator[bytes]]] = None self._name: Optional[str] = name self._shared: Optional[bool] = False self._updated_at: Optional["datetime"] = None @@ -40,6 +43,12 @@ def __repr__(self: "CustomViewItem"): def _set_image(self, image): self._image = image + def _set_pdf(self, pdf): + self._pdf = pdf + + def _set_csv(self, csv): + self._csv = csv + @property def content_url(self) -> Optional[str]: return self._content_url @@ -55,10 +64,24 @@ def id(self) -> Optional[str]: @property def image(self) -> bytes: if self._image is None: - error = "View item must be populated with its png image first." + error = "Custom View item must be populated with its png image first." raise UnpopulatedPropertyError(error) return self._image() + @property + def pdf(self) -> bytes: + if self._pdf is None: + error = "Custom View item must be populated with its pdf first." + raise UnpopulatedPropertyError(error) + return self._pdf() + + @property + def csv(self) -> Iterator[bytes]: + if self._csv is None: + error = "Custom View item must be populated with its csv first." + raise UnpopulatedPropertyError(error) + return self._csv() + @property def name(self) -> Optional[str]: return self._name diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 3e4fec22..bb348727 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -36,11 +36,13 @@ class Capability: ShareView = "ShareView" ViewComments = "ViewComments" ViewUnderlyingData = "ViewUnderlyingData" + VizqlDataApiAccess = "VizqlDataApiAccess" WebAuthoring = "WebAuthoring" Write = "Write" RunExplainData = "RunExplainData" CreateRefreshMetrics = "CreateRefreshMetrics" SaveAs = "SaveAs" + PulseMetricDefine = "PulseMetricDefine" def __repr__(self): return "" diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index d875abbd..48f27c60 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -9,6 +9,8 @@ class ProjectItem: + ERROR_MSG = "Project item must be populated with permissions first." + class ContentPermissions: LockedToProject: str = "LockedToProject" ManagedByOwner: str = "ManagedByOwner" @@ -43,6 +45,9 @@ def __init__( self._default_lens_permissions = None self._default_datarole_permissions = None self._default_metric_permissions = None + self._default_virtualconnection_permissions = None + self._default_database_permissions = None + self._default_table_permissions = None @property def content_permissions(self): @@ -56,52 +61,63 @@ def content_permissions(self, value: Optional[str]) -> None: @property def permissions(self): if self._permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._permissions() @property def default_datasource_permissions(self): if self._default_datasource_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_datasource_permissions() @property def default_workbook_permissions(self): if self._default_workbook_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_workbook_permissions() @property def default_flow_permissions(self): if self._default_flow_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_flow_permissions() @property def default_lens_permissions(self): if self._default_lens_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_lens_permissions() @property def default_datarole_permissions(self): if self._default_datarole_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_datarole_permissions() @property def default_metric_permissions(self): if self._default_metric_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_metric_permissions() + @property + def default_virtualconnection_permissions(self): + if self._default_virtualconnection_permissions is None: + raise UnpopulatedPropertyError(self.ERROR_MSG) + return self._default_virtualconnection_permissions() + + @property + def default_database_permissions(self): + if self._default_database_permissions is None: + raise UnpopulatedPropertyError(self.ERROR_MSG) + return self._default_database_permissions() + + @property + def default_table_permissions(self): + if self._default_table_permissions is None: + raise UnpopulatedPropertyError(self.ERROR_MSG) + return self._default_table_permissions() + @property def id(self) -> Optional[str]: return self._id diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index 5c3f6acc..b13f2674 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -7,6 +7,28 @@ class ServerInfoItem: + """ + The ServerInfoItem class contains the build and version information for + Tableau Server. The server information is accessed with the + server_info.get() method, which returns an instance of the ServerInfo class. + + Attributes + ---------- + product_version : str + Shows the version of the Tableau Server or Tableau Cloud + (for example, 10.2.0). + + build_number : str + Shows the specific build number (for example, 10200.17.0329.1446). + + rest_api_version : str + Shows the supported REST API version number. Note that this might be + different from the default value specified for the server, with the + Server.version attribute. To take advantage of new features, you should + query the server and set the Server.version to match the supported REST + API version number. + """ + def __init__(self, product_version, build_number, rest_api_version): self._product_version = product_version self._build_number = build_number @@ -40,13 +62,11 @@ def from_response(cls, resp, ns): try: parsed_response = fromstring(resp) except xml.etree.ElementTree.ParseError as error: - logger.info(f"Unexpected response for ServerInfo: {resp}") - logger.info(error) + logger.exception(f"Unexpected response for ServerInfo: {resp}") return cls("Unknown", "Unknown", "Unknown") except Exception as error: - logger.info(f"Unexpected response for ServerInfo: {resp}") - logger.info(error) - return cls("Unknown", "Unknown", "Unknown") + logger.exception(f"Unexpected response for ServerInfo: {resp}") + raise error product_version_tag = parsed_response.find(".//t:productVersion", namespaces=ns) rest_api_version_tag = parsed_response.find(".//t:restApiVersion", namespaces=ns) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 2d9f014a..e4e146f9 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -21,6 +21,72 @@ class SiteItem: + """ + The SiteItem class contains the members or attributes for the site resources + on Tableau Server or Tableau Cloud. The SiteItem class defines the + information you can request or query from Tableau Server or Tableau Cloud. + The class members correspond to the attributes of a server request or + response payload. + + Attributes + ---------- + name: str + The name of the site. The name of the default site is "". + + content_url: str + The path to the site. + + admin_mode: str + (Optional) For Tableau Server only. Specify ContentAndUsers to allow + site administrators to use the server interface and tabcmd commands to + add and remove users. (Specifying this option does not give site + administrators permissions to manage users using the REST API.) Specify + ContentOnly to prevent site administrators from adding or removing + users. (Server administrators can always add or remove users.) + + user_quota: int + (Optional) Specifies the total number of users for the site. The number + can't exceed the number of licenses activated for the site; and if + tiered capacity attributes are set, then user_quota will equal the sum + of the tiered capacity values, and attempting to set user_quota will + cause an error. + + tier_explorer_capacity: int + tier_creator_capacity: int + tier_viewer_capacity: int + (Optional) The maximum number of licenses for users with the Creator, + Explorer, or Viewer role, respectively, allowed on a site. + + storage_quota: int + (Optional) Specifies the maximum amount of space for the new site, in + megabytes. If you set a quota and the site exceeds it, publishers will + be prevented from uploading new content until the site is under the + limit again. + + disable_subscriptions: bool + (Optional) Specify true to prevent users from being able to subscribe + to workbooks on the specified site. The default is False. + + subscribe_others_enabled: bool + (Optional) Specify false to prevent server administrators, site + administrators, and project or content owners from being able to + subscribe other users to workbooks on the specified site. The default + is True. + + revision_history_enabled: bool + (Optional) Specify true to enable revision history for content resources + (workbooks and datasources). The default is False. + + revision_limit: int + (Optional) Specifies the number of revisions of a content source + (workbook or data source) to allow. On Tableau Server, the default is + 25. + + state: str + Shows the current state of the site (Active or Suspended). + + """ + _user_quota: Optional[int] = None _tier_creator_capacity: Optional[int] = None _tier_explorer_capacity: Optional[int] = None diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index 61c75e2d..d67cda91 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING +from typing import List, Type, TYPE_CHECKING, Optional from defusedxml.ElementTree import fromstring @@ -11,20 +11,21 @@ class SubscriptionItem: - def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None: + def __init__(self, subject: str, schedule_id: str | None, user_id: str | None, target: "Target") -> None: self._id = None - self.attach_image = True - self.attach_pdf = False - self.message = None - self.page_orientation = None - self.page_size_option = None - self.schedule_id = schedule_id - self.send_if_view_empty = True - self.subject = subject - self.suspended = False - self.target = target - self.user_id = user_id - self.schedule = None + + self.attach_image: bool = True # chosen as default value + self.attach_pdf: bool = False + self.message: Optional[str] = None + self.page_orientation: Optional[str] = None + self.page_size_option: Optional[str] = None + self.schedule_id: Optional[str] = schedule_id + self.send_if_view_empty: bool = True + self.subject: str = subject + self.suspended: bool = False + self.target: Target = target + self.user_id: Optional[str] = user_id + self.schedule: Optional[ScheduleItem] = None def __repr__(self) -> str: if self.id is not None: diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index c1e9d62b..7d798143 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -32,6 +32,43 @@ def deprecate_site_attribute(): # The traditional auth type: username/password class TableauAuth(Credentials): + """ + The TableauAuth class defines the information you can set in a sign-in + request. The class members correspond to the attributes of a server request + or response payload. To use this class, create a new instance, supplying + user name, password, and site information if necessary, and pass the + request object to the Auth.sign_in method. + + Parameters + ---------- + username : str + The user name for the sign-in request. + + password : str + The password for the sign-in request. + + site_id : str, optional + This corresponds to the contentUrl attribute in the Tableau REST API. + The site_id is the portion of the URL that follows the /site/ in the + URL. For example, "MarketingTeam" is the site_id in the following URL + MyServer/#/site/MarketingTeam/projects. To specify the default site on + Tableau Server, you can use an empty string '' (single quotes, no + space). For Tableau Cloud, you must provide a value for the site_id. + + user_id_to_impersonate : str, optional + Specifies the id (not the name) of the user to sign in as. This is not + available for Tableau Online. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD', site_id='CONTENTURL') + >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) + >>> server.auth.sign_in(tableau_auth) + + """ + def __init__( self, username: str, password: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None ) -> None: @@ -55,6 +92,43 @@ def __repr__(self): # A Tableau-generated Personal Access Token class PersonalAccessTokenAuth(Credentials): + """ + The PersonalAccessTokenAuth class defines the information you can set in a sign-in + request. The class members correspond to the attributes of a server request + or response payload. To use this class, create a new instance, supplying + token name, token secret, and site information if necessary, and pass the + request object to the Auth.sign_in method. + + Parameters + ---------- + token_name : str + The name of the personal access token. + + personal_access_token : str + The personal access token secret for the sign in request. + + site_id : str, optional + This corresponds to the contentUrl attribute in the Tableau REST API. + The site_id is the portion of the URL that follows the /site/ in the + URL. For example, "MarketingTeam" is the site_id in the following URL + MyServer/#/site/MarketingTeam/projects. To specify the default site on + Tableau Server, you can use an empty string '' (single quotes, no + space). For Tableau Cloud, you must provide a value for the site_id. + + user_id_to_impersonate : str, optional + Specifies the id (not the name) of the user to sign in as. This is not + available for Tableau Online. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> tableau_auth = TSC.PersonalAccessTokenAuth("token_name", "token_secret", site_id='CONTENTURL') + >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) + >>> server.auth.sign_in(tableau_auth) + + """ + def __init__( self, token_name: str, @@ -88,6 +162,42 @@ def __repr__(self): # A standard JWT generated specifically for Tableau class JWTAuth(Credentials): + """ + The JWTAuth class defines the information you can set in a sign-in + request. The class members correspond to the attributes of a server request + or response payload. To use this class, create a new instance, supplying + an encoded JSON Web Token, and site information if necessary, and pass the + request object to the Auth.sign_in method. + + Parameters + ---------- + token : str + The encoded JSON Web Token. + + site_id : str, optional + This corresponds to the contentUrl attribute in the Tableau REST API. + The site_id is the portion of the URL that follows the /site/ in the + URL. For example, "MarketingTeam" is the site_id in the following URL + MyServer/#/site/MarketingTeam/projects. To specify the default site on + Tableau Server, you can use an empty string '' (single quotes, no + space). For Tableau Cloud, you must provide a value for the site_id. + + user_id_to_impersonate : str, optional + Specifies the id (not the name) of the user to sign in as. This is not + available for Tableau Online. + + Examples + -------- + >>> import jwt + >>> import tableauserverclient as TSC + + >>> jwt_token = jwt.encode(...) + >>> tableau_auth = TSC.JWTAuth(token, site_id='CONTENTURL') + >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) + >>> server.auth.sign_in(tableau_auth) + + """ + def __init__(self, jwt: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None) -> None: if jwt is None: raise TabError("Must provide a JWT token when using JWT authentication") diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index ea2a5e4f..01ee3d3a 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -28,7 +28,7 @@ class Resource: TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem, VirtualConnectionItem] -def plural_type(content_type: Resource) -> str: +def plural_type(content_type: Union[Resource, str]) -> str: if content_type == Resource.Lens: return "lenses" else: diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index fb29492e..365e44c1 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -19,9 +19,34 @@ class UserItem: + """ + The UserItem class contains the members or attributes for the view + resources on Tableau Server. The UserItem class defines the information you + can request or query from Tableau Server. The class attributes correspond + to the attributes of a server request or response payload. + + + Parameters + ---------- + name: str + The name of the user. + + site_role: str + The role of the user on the site. + + auth_setting: str + Required attribute for Tableau Cloud. How the user autenticates to the + server. + """ + tag_name: str = "user" class Roles: + """ + The Roles class contains the possible roles for a user on Tableau + Server. + """ + Interactor = "Interactor" Publisher = "Publisher" ServerAdministrator = "ServerAdministrator" @@ -43,6 +68,11 @@ class Roles: SupportUser = "SupportUser" class Auth: + """ + The Auth class contains the possible authentication settings for a user + on Tableau Cloud. + """ + OpenID = "OpenID" SAML = "SAML" TableauIDWithMFA = "TableauIDWithMFA" diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index ab5ff415..776d041e 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -21,6 +21,84 @@ class WorkbookItem: + """ + The workbook resources for Tableau are defined in the WorkbookItem class. + The class corresponds to the workbook resources you can access using the + Tableau REST API. Some workbook methods take an instance of the WorkbookItem + class as arguments. The workbook item specifies the project. + + Parameters + ---------- + project_id : Optional[str], optional + The project ID for the workbook, by default None. + + name : Optional[str], optional + The name of the workbook, by default None. + + show_tabs : bool, optional + Determines whether the workbook shows tabs for the view. + + Attributes + ---------- + connections : list[ConnectionItem] + The list of data connections (ConnectionItem) for the data sources used + by the workbook. You must first call the workbooks.populate_connections + method to access this data. See the ConnectionItem class. + + content_url : Optional[str] + The name of the workbook as it appears in the URL. + + created_at : Optional[datetime.datetime] + The date and time the workbook was created. + + description : Optional[str] + User-defined description of the workbook. + + 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. + + owner_id : Optional[str] + The identifier for the owner (UserItem) of the workbook. + + preview_image : bytes + The thumbnail image for the view. You must first call the + workbooks.populate_preview_image method to access this data. + + project_name : Optional[str] + The name of the project that contains the workbook. + + size: int + The size of the workbook in megabytes. + + hidden_views: Optional[list[str]] + List of string names of views that need to be hidden when the workbook + is published. + + tags: set[str] + The set of tags associated with the workbook. + + updated_at : Optional[datetime.datetime] + The date and time the workbook was last updated. + + views : list[ViewItem] + The list of views (ViewItem) for the workbook. You must first call the + workbooks.populate_views method to access this data. See the ViewItem + class. + + web_page_url : Optional[str] + The full URL for the workbook. + + Examples + -------- + # creating a new instance of a WorkbookItem + >>> import tableauserverclient as TSC + + >>> # Create new workbook_item with project id '3a8b6148-493c-11e6-a621-6f3499394a39' + + >>> new_workbook = TSC.WorkbookItem('3a8b6148-493c-11e6-a621-6f3499394a39') + """ + def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, show_tabs: bool = False) -> None: self._connections = None self._content_url = None diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index f5cd1d23..87cc9460 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -11,7 +11,7 @@ from tableauserverclient.server.sort import Sort from tableauserverclient.server.server import Server from tableauserverclient.server.pager import Pager -from tableauserverclient.server.endpoint.exceptions import NotSignedInError +from tableauserverclient.server.endpoint.exceptions import FailedSignInError, NotSignedInError from tableauserverclient.server.endpoint import ( Auth, @@ -57,6 +57,7 @@ "Sort", "Server", "Pager", + "FailedSignInError", "NotSignedInError", "Auth", "CustomViews", diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 231052f7..4211bb7e 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -41,6 +41,30 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: optionally a user_id to impersonate. Creates a context manager that will sign out of the server upon exit. + + Parameters + ---------- + auth_req : Credentials + The credentials object to use for signing in. Can be a TableauAuth, + PersonalAccessTokenAuth, or JWTAuth object. + + Returns + ------- + contextmgr + A context manager that will sign out of the server upon exit. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create an auth object + >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') + + >>> # create an instance for your server + >>> server = TSC.Server('https://SERVER_URL') + + >>> # call the sign-in method with the auth object + >>> server.auth.sign_in(tableau_auth) """ url = f"{self.baseurl}/signin" signin_req = RequestFactory.Auth.signin_req(auth_req) @@ -70,14 +94,17 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: # The distinct methods are mostly useful for explicitly showing api version support for each auth type @api(version="3.6") def sign_in_with_personal_access_token(self, auth_req: "Credentials") -> contextmgr: + """Passthrough to sign_in method""" return self.sign_in(auth_req) @api(version="3.17") def sign_in_with_json_web_token(self, auth_req: "Credentials") -> contextmgr: + """Passthrough to sign_in method""" return self.sign_in(auth_req) @api(version="2.0") def sign_out(self) -> None: + """Sign out of current session.""" url = f"{self.baseurl}/signout" # If there are no auth tokens you're already signed out. No-op if not self.parent_srv.is_signed_in(): @@ -88,6 +115,33 @@ def sign_out(self) -> None: @api(version="2.6") def switch_site(self, site_item: "SiteItem") -> contextmgr: + """ + Switch to a different site on the server. This will sign out of the + current site and sign in to the new site. If used as a context manager, + will sign out of the new site upon exit. + + Parameters + ---------- + site_item : SiteItem + The site to switch to. + + Returns + ------- + contextmgr + A context manager that will sign out of the new site upon exit. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # Find the site you want to switch to + >>> new_site = server.sites.get_by_id("9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d") + >>> # switch to the new site + >>> with server.auth.switch_site(new_site): + >>> # do something on the new site + >>> pass + + """ url = f"{self.baseurl}/switchSite" switch_req = RequestFactory.Auth.switch_req(site_item.content_url) try: @@ -109,6 +163,9 @@ def switch_site(self, site_item: "SiteItem") -> contextmgr: @api(version="3.10") def revoke_all_server_admin_tokens(self) -> None: + """ + Revokes all personal access tokens for all server admins on the server. + """ url = f"{self.baseurl}/revokeAllServerAdminTokens" self.post_request(url, "") logger.info("Revoked all tokens for all server admins") diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index baed9114..b02b05d7 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -1,15 +1,23 @@ import io import logging import os +from contextlib import closing from pathlib import Path from typing import Optional, Union +from collections.abc import Iterator -from tableauserverclient.config import BYTES_PER_MB, FILESIZE_LIMIT_MB +from tableauserverclient.config import BYTES_PER_MB, config from tableauserverclient.filesys_helpers import get_file_object_size from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.models import CustomViewItem, PaginationItem -from tableauserverclient.server import RequestFactory, RequestOptions, ImageRequestOptions +from tableauserverclient.server import ( + RequestFactory, + RequestOptions, + ImageRequestOptions, + PDFRequestOptions, + CSVRequestOptions, +) from tableauserverclient.helpers.logging import logger @@ -91,9 +99,45 @@ def _get_view_image(self, view_item: CustomViewItem, req_options: Optional["Imag image = server_response.content return image - """ - Not yet implemented: pdf or csv exports - """ + @api(version="3.23") + def populate_pdf(self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"] = None) -> None: + if not custom_view_item.id: + error = "Custom View item missing ID." + raise MissingRequiredFieldError(error) + + def pdf_fetcher(): + return self._get_custom_view_pdf(custom_view_item, req_options) + + custom_view_item._set_pdf(pdf_fetcher) + logger.info(f"Populated pdf for custom view (ID: {custom_view_item.id})") + + def _get_custom_view_pdf( + self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"] + ) -> bytes: + url = f"{self.baseurl}/{custom_view_item.id}/pdf" + server_response = self.get_request(url, req_options) + pdf = server_response.content + return pdf + + @api(version="3.23") + def populate_csv(self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None: + if not custom_view_item.id: + error = "Custom View item missing ID." + raise MissingRequiredFieldError(error) + + def csv_fetcher(): + return self._get_custom_view_csv(custom_view_item, req_options) + + custom_view_item._set_csv(csv_fetcher) + logger.info(f"Populated csv for custom view (ID: {custom_view_item.id})") + + def _get_custom_view_csv( + self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"] + ) -> Iterator[bytes]: + url = f"{self.baseurl}/{custom_view_item.id}/data" + + with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: + yield from server_response.iter_content(1024) @api(version="3.18") def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: @@ -144,7 +188,7 @@ def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> Optional[Cust else: raise ValueError("File path or file object required for publishing custom view.") - if size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: + if size >= config.FILESIZE_LIMIT_MB * BYTES_PER_MB: upload_session_id = self.parent_srv.fileuploads.upload(file) url = f"{url}?uploadSessionId={upload_session_id}" xml_request, content_type = RequestFactory.CustomView.publish_req_chunked(view_item) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 38ef5075..6bd809c2 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -23,7 +23,7 @@ from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin -from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, config +from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, BYTES_PER_MB, config from tableauserverclient.filesys_helpers import ( make_download_path, get_file_type, @@ -268,10 +268,10 @@ def publish( url += "&{}=true".format("asJob") # Determine if chunking is required (64MB is the limit for single upload method) - if file_size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: + if file_size >= config.FILESIZE_LIMIT_MB * BYTES_PER_MB: logger.info( "Publishing {} to server with chunking method (datasource over {}MB, chunk size {}MB)".format( - filename, FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB + filename, config.FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB ) ) upload_session_id = self.parent_srv.fileuploads.upload(file) diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 343d8b09..499324e8 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -39,7 +39,7 @@ def __str__(self): __repr__ = __str__ def update_default_permissions( - self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Resource + self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Union[Resource, str] ) -> list[PermissionsRule]: url = f"{self.owner_baseurl()}/{resource.id}/default-permissions/{plural_type(content_type)}" update_req = RequestFactory.Permission.add_req(permissions) @@ -50,7 +50,9 @@ def update_default_permissions( return permissions - def delete_default_permission(self, resource: BaseItem, rule: PermissionsRule, content_type: Resource) -> None: + def delete_default_permission( + self, resource: BaseItem, rule: PermissionsRule, content_type: Union[Resource, str] + ) -> None: for capability, mode in rule.capabilities.items(): # Made readability better but line is too long, will make this look better url = ( @@ -72,7 +74,7 @@ def delete_default_permission(self, resource: BaseItem, rule: PermissionsRule, c logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}") - def populate_default_permissions(self, item: BaseItem, content_type: Resource) -> None: + def populate_default_permissions(self, item: BaseItem, content_type: Union[Resource, str]) -> None: if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -84,7 +86,7 @@ def permission_fetcher() -> list[PermissionsRule]: logger.info(f"Populated default {content_type} permissions for item (ID: {item.id})") def _get_default_permissions( - self, item: BaseItem, content_type: Resource, req_options: Optional["RequestOptions"] = None + self, item: BaseItem, content_type: Union[Resource, str], req_options: Optional["RequestOptions"] = None ) -> list[PermissionsRule]: url = f"{self.owner_baseurl()}/{item.id}/default-permissions/{plural_type(content_type)}" server_response = self.get_request(url, req_options) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 29912de6..9e116070 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -19,6 +19,7 @@ from tableauserverclient.server.request_options import RequestOptions from tableauserverclient.server.endpoint.exceptions import ( + FailedSignInError, ServerResponseError, InternalServerError, NonXMLResponseError, @@ -160,7 +161,7 @@ def _check_status(self, server_response: "Response", url: Optional[str] = None): try: if server_response.status_code == 401: # TODO: catch this in server.py and attempt to sign in again, in case it's a session expiry - raise NotSignedInError(server_response.content, url) + raise FailedSignInError.from_response(server_response.content, self.parent_srv.namespace, url) raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace, url) except ParseError: diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 17d789d0..77332da3 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -1,13 +1,20 @@ from defusedxml.ElementTree import fromstring -from typing import Optional +from typing import Mapping, Optional, TypeVar + + +def split_pascal_case(s: str) -> str: + return "".join([f" {c}" if c.isupper() else c for c in s]).strip() class TableauError(Exception): pass -class ServerResponseError(TableauError): - def __init__(self, code, summary, detail, url=None): +T = TypeVar("T") + + +class XMLError(TableauError): + def __init__(self, code: str, summary: str, detail: str, url: Optional[str] = None) -> None: self.code = code self.summary = summary self.detail = detail @@ -18,7 +25,7 @@ def __str__(self): return f"\n\n\t{self.code}: {self.summary}\n\t\t{self.detail}" @classmethod - def from_response(cls, resp, ns, url=None): + def from_response(cls, resp, ns, url): # Check elements exist before .text parsed_response = fromstring(resp) try: @@ -33,6 +40,10 @@ def from_response(cls, resp, ns, url=None): return error_response +class ServerResponseError(XMLError): + pass + + class InternalServerError(TableauError): def __init__(self, server_response, request_url: Optional[str] = None): self.code = server_response.status_code @@ -51,6 +62,11 @@ class NotSignedInError(TableauError): pass +class FailedSignInError(XMLError, NotSignedInError): + def __str__(self): + return f"{split_pascal_case(self.__class__.__name__)}: {super().__str__()}" + + class ItemTypeNotAllowed(TableauError): pass diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 4d139fe6..74bb865c 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -5,6 +5,7 @@ from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server import RequestFactory, RequestOptions +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.models import ProjectItem, PaginationItem, Resource from typing import Optional, TYPE_CHECKING @@ -78,85 +79,135 @@ def populate_permissions(self, item: ProjectItem) -> None: self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, item, rules): + def update_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._permissions.update(item, rules) @api(version="2.0") - def delete_permission(self, item, rules): + def delete_permission(self, item: ProjectItem, rules: list[PermissionsRule]) -> None: self._permissions.delete(item, rules) @api(version="2.1") - def populate_workbook_default_permissions(self, item): + def populate_workbook_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Workbook) @api(version="2.1") - def populate_datasource_default_permissions(self, item): + def populate_datasource_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Datasource) @api(version="3.2") - def populate_metric_default_permissions(self, item): + def populate_metric_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Metric) @api(version="3.4") - def populate_datarole_default_permissions(self, item): + def populate_datarole_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Datarole) @api(version="3.4") - def populate_flow_default_permissions(self, item): + def populate_flow_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Flow) @api(version="3.4") - def populate_lens_default_permissions(self, item): + def populate_lens_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Lens) + @api(version="3.23") + def populate_virtualconnection_default_permissions(self, item: ProjectItem) -> None: + self._default_permissions.populate_default_permissions(item, Resource.VirtualConnection) + + @api(version="3.23") + def populate_database_default_permissions(self, item: ProjectItem) -> None: + self._default_permissions.populate_default_permissions(item, Resource.Database) + + @api(version="3.23") + def populate_table_default_permissions(self, item: ProjectItem) -> None: + self._default_permissions.populate_default_permissions(item, Resource.Table) + @api(version="2.1") - def update_workbook_default_permissions(self, item, rules): + def update_workbook_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Workbook) @api(version="2.1") - def update_datasource_default_permissions(self, item, rules): + def update_datasource_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Datasource) @api(version="3.2") - def update_metric_default_permissions(self, item, rules): + def update_metric_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Metric) @api(version="3.4") - def update_datarole_default_permissions(self, item, rules): + def update_datarole_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Datarole) @api(version="3.4") - def update_flow_default_permissions(self, item, rules): + def update_flow_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Flow) @api(version="3.4") - def update_lens_default_permissions(self, item, rules): + def update_lens_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Lens) + @api(version="3.23") + def update_virtualconnection_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: + return self._default_permissions.update_default_permissions(item, rules, Resource.VirtualConnection) + + @api(version="3.23") + def update_database_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: + return self._default_permissions.update_default_permissions(item, rules, Resource.Database) + + @api(version="3.23") + def update_table_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: + return self._default_permissions.update_default_permissions(item, rules, Resource.Table) + @api(version="2.1") - def delete_workbook_default_permissions(self, item, rule): + def delete_workbook_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Workbook) @api(version="2.1") - def delete_datasource_default_permissions(self, item, rule): + def delete_datasource_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Datasource) @api(version="3.2") - def delete_metric_default_permissions(self, item, rule): + def delete_metric_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Metric) @api(version="3.4") - def delete_datarole_default_permissions(self, item, rule): + def delete_datarole_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Datarole) @api(version="3.4") - def delete_flow_default_permissions(self, item, rule): + def delete_flow_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Flow) @api(version="3.4") - def delete_lens_default_permissions(self, item, rule): + def delete_lens_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Lens) + @api(version="3.23") + def delete_virtualconnection_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + self._default_permissions.delete_default_permission(item, rule, Resource.VirtualConnection) + + @api(version="3.23") + def delete_database_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + self._default_permissions.delete_default_permission(item, rule, Resource.Database) + + @api(version="3.23") + def delete_table_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + self._default_permissions.delete_default_permission(item, rule, Resource.Table) + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ProjectItem]: """ Queries the Tableau Server for items using the specified filters. Page diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 4ed243b2..eec4536f 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -115,8 +115,7 @@ def add_to_schedule( ) # type:ignore[arg-type] results = (self._add_to(*x) for x in items) - # list() is needed for python 3.x compatibility - return list(filter(lambda x: not x.result, results)) # type:ignore[arg-type] + return [x for x in results if not x.result] def _add_to( self, diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index ab731c11..dc934496 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -1,4 +1,5 @@ import logging +from typing import Union from .endpoint import Endpoint, api from .exceptions import ServerResponseError @@ -24,12 +25,46 @@ def __repr__(self): return f"" @property - def baseurl(self): + def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/serverInfo" @api(version="2.4") - def get(self): - """Retrieve the server info for the server. This is an unauthenticated call""" + def get(self) -> Union[ServerInfoItem, None]: + """ + Retrieve the build and version information for the server. + + This method makes an unauthenticated call, so no sign in or + authentication token is required. + + Returns + ------- + :class:`~tableauserverclient.models.ServerInfoItem` + + Raises + ------ + :class:`~tableauserverclient.exceptions.ServerInfoEndpointNotFoundError` + Raised when the server info endpoint is not found. + + :class:`~tableauserverclient.exceptions.EndpointUnavailableError` + Raised when the server info endpoint is not available. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create a instance of server + >>> server = TSC.Server('https://MY-SERVER') + + >>> # set the version number > 2.3 + >>> # the server_info.get() method works in 2.4 and later + >>> server.version = '2.5' + + >>> s_info = server.server_info.get() + >>> print("\nServer info:") + >>> print("\tProduct version: {0}".format(s_info.product_version)) + >>> print("\tREST API version: {0}".format(s_info.rest_api_version)) + >>> print("\tBuild number: {0}".format(s_info.build_number)) + """ try: server_response = self.get_unauthenticated_request(self.baseurl) except ServerResponseError as e: diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 0f3d2590..55d2a5ad 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -15,6 +15,16 @@ class Sites(Endpoint): + """ + Using the site methods of the Tableau Server REST API you can: + + List sites on a server or get details of a specific site + Create, update, or delete a site + List views in a site + Encrypt, decrypt, or reencrypt extracts on a site + + """ + @property def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites" @@ -22,6 +32,25 @@ def baseurl(self) -> str: # Gets all sites @api(version="2.0") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[SiteItem], PaginationItem]: + """ + Query all sites on the server. This method requires server admin + permissions. This endpoint is paginated, meaning that the server will + only return a subset of the data at a time. The response will contain + information about the total number of sites and the number of sites + returned in the current response. Use the PaginationItem object to + request more data. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_sites + + Parameters + ---------- + req_options : RequestOptions, optional + Filtering options for the request. + + Returns + ------- + tuple[list[SiteItem], PaginationItem] + """ logger.info("Querying all sites on site") logger.info("Requires Server Admin permissions") url = self.baseurl @@ -33,6 +62,33 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Site # Gets 1 site by id @api(version="2.0") def get_by_id(self, site_id: str) -> SiteItem: + """ + Query a single site on the server. You can only retrieve the site that + you are currently authenticated for. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site + + Parameters + ---------- + site_id : str + The site ID. + + Returns + ------- + SiteItem + + Raises + ------ + ValueError + If the site ID is not defined. + + ValueError + If the site ID does not match the site for which you are currently authenticated. + + Examples + -------- + >>> site = server.sites.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -48,6 +104,31 @@ def get_by_id(self, site_id: str) -> SiteItem: # Gets 1 site by name @api(version="2.0") def get_by_name(self, site_name: str) -> SiteItem: + """ + Query a single site on the server. You can only retrieve the site that + you are currently authenticated for. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site + + Parameters + ---------- + site_name : str + The site name. + + Returns + ------- + SiteItem + + Raises + ------ + ValueError + If the site name is not defined. + + Examples + -------- + >>> site = server.sites.get_by_name('Tableau') + + """ if not site_name: error = "Site Name undefined." raise ValueError(error) @@ -61,6 +142,31 @@ def get_by_name(self, site_name: str) -> SiteItem: # Gets 1 site by content url @api(version="2.0") def get_by_content_url(self, content_url: str) -> SiteItem: + """ + Query a single site on the server. You can only retrieve the site that + you are currently authenticated for. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site + + Parameters + ---------- + content_url : str + The content URL. + + Returns + ------- + SiteItem + + Raises + ------ + ValueError + If the site name is not defined. + + Examples + -------- + >>> site = server.sites.get_by_name('Tableau') + + """ if content_url is None: error = "Content URL undefined." raise ValueError(error) @@ -77,6 +183,42 @@ def get_by_content_url(self, content_url: str) -> SiteItem: # Update site @api(version="2.0") def update(self, site_item: SiteItem) -> SiteItem: + """ + Modifies the settings for site. + + The site item object must include the site ID and overrides all other settings. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#update_site + + Parameters + ---------- + site_item : SiteItem + The site item that you want to update. The settings specified in the + site item override the current site settings. + + Returns + ------- + SiteItem + The site item object that was updated. + + Raises + ------ + MissingRequiredFieldError + If the site item is missing an ID. + + ValueError + If the site ID does not match the site for which you are currently authenticated. + + ValueError + If the site admin mode is set to ContentOnly and a user quota is also set. + + Examples + -------- + >>> ... + >>> site_item.name = 'New Name' + >>> updated_site = server.sites.update(site_item) + + """ if not site_item.id: error = "Site item missing ID." raise MissingRequiredFieldError(error) @@ -100,6 +242,29 @@ def update(self, site_item: SiteItem) -> SiteItem: # Delete 1 site object @api(version="2.0") def delete(self, site_id: str) -> None: + """ + Deletes the specified site from the server. You can only delete the site + if you are a Server Admin. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_site + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + ValueError + If the site ID does not match the site for which you are currently authenticated. + + Examples + -------- + >>> server.sites.delete('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -114,6 +279,47 @@ def delete(self, site_id: str) -> None: # Create new site @api(version="2.0") def create(self, site_item: SiteItem) -> SiteItem: + """ + Creates a new site on the server for the specified site item object. + + Tableau Server only. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_site + + Parameters + ---------- + site_item : SiteItem + The settings for the site that you want to create. You need to + create an instance of SiteItem and pass it to the create method. + + Returns + ------- + SiteItem + The site item object that was created. + + Raises + ------ + ValueError + If the site admin mode is set to ContentOnly and a user quota is also set. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create an instance of server + >>> server = TSC.Server('https://MY-SERVER') + + >>> # create shortcut for admin mode + >>> content_users=TSC.SiteItem.AdminMode.ContentAndUsers + + >>> # create a new SiteItem + >>> new_site = TSC.SiteItem(name='Tableau', content_url='tableau', admin_mode=content_users, user_quota=15, storage_quota=1000, disable_subscriptions=True) + + >>> # call the sites create method with the SiteItem + >>> new_site = server.sites.create(new_site) + + + """ if site_item.admin_mode: if site_item.admin_mode == SiteItem.AdminMode.ContentOnly and site_item.user_quota: error = "You cannot set admin_mode to ContentOnly and also set a user quota" @@ -128,6 +334,25 @@ def create(self, site_item: SiteItem) -> SiteItem: @api(version="3.5") def encrypt_extracts(self, site_id: str) -> None: + """ + Encrypts all extracts on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#encrypt_extracts + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + Examples + -------- + >>> server.sites.encrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -137,6 +362,25 @@ def encrypt_extracts(self, site_id: str) -> None: @api(version="3.5") def decrypt_extracts(self, site_id: str) -> None: + """ + Decrypts all extracts on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#decrypt_extracts + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + Examples + -------- + >>> server.sites.decrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -146,6 +390,27 @@ def decrypt_extracts(self, site_id: str) -> None: @api(version="3.5") def re_encrypt_extracts(self, site_id: str) -> None: + """ + Reencrypt all extracts on a site with new encryption keys. If no site is + specified, extracts on the default site will be reencrypted. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#reencrypt_extracts + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + Examples + -------- + >>> server.sites.re_encrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + + """ if not site_id: error = "Site ID undefined." raise ValueError(error) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 79363839..d81907ae 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -14,6 +14,14 @@ class Users(QuerysetEndpoint[UserItem]): + """ + The user resources for Tableau Server are defined in the UserItem class. + The class corresponds to the user resources you can access using the + Tableau Server REST API. The user methods are based upon the endpoints for + users in the REST API and operate on the UserItem class. Only server and + site administrators can access the user resources. + """ + @property def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/users" @@ -21,6 +29,60 @@ def baseurl(self) -> str: # Gets all users @api(version="2.0") def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserItem], PaginationItem]: + """ + Query all users on the site. Request is paginated and returns a subset of users. + By default, the request returns the first 100 users on the site. + + Parameters + ---------- + req_options : Optional[RequestOptions] + Optional request options to filter and sort the results. + + Returns + ------- + tuple[list[UserItem], PaginationItem] + Returns a tuple with a list of UserItem objects and a PaginationItem object. + + Raises + ------ + ServerResponseError + code: 400006 + summary: Invalid page number + detail: The page number is not an integer, is less than one, or is + greater than the final page number for users at the requested + page size. + + ServerResponseError + code: 400007 + summary: Invalid page size + detail: The page size parameter is not an integer, is less than one. + + ServerResponseError + code: 403014 + summary: Page size limit exceeded + detail: The specified page size is larger than the maximum page size + + ServerResponseError + code: 404000 + summary: Site not found + detail: The site ID in the URI doesn't correspond to an existing site. + + ServerResponseError + code: 405000 + summary: Invalid request method + detail: Request type was not GET. + + Examples + -------- + >>> import tableauserverclient as TSC + >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') + >>> server = TSC.Server('https://SERVERURL') + + >>> with server.auth.sign_in(tableau_auth): + >>> users_page, pagination_item = server.users.get() + >>> print("\nThere are {} user on site: ".format(pagination_item.total_available)) + >>> print([user.name for user in users_page]) + """ logger.info("Querying all users on site") if req_options is None: @@ -36,6 +98,49 @@ def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserIt # Gets 1 user by id @api(version="2.0") def get_by_id(self, user_id: str) -> UserItem: + """ + Query a single user by ID. + + Parameters + ---------- + user_id : str + The ID of the user to query. + + Returns + ------- + UserItem + The user item that was queried. + + Raises + ------ + ValueError + If the user ID is not specified. + + ServerResponseError + code: 404000 + summary: Site not found + detail: The site ID in the URI doesn't correspond to an existing site. + + ServerResponseError + code: 403133 + summary: Query user permissions forbidden + detail: The user does not have permissions to query user information + for other users + + ServerResponseError + code: 404002 + summary: User not found + detail: The user ID in the URI doesn't correspond to an existing user. + + ServerResponseError + code: 405000 + summary: Invalid request method + detail: Request type was not GET. + + Examples + -------- + >>> user1 = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + """ if not user_id: error = "User ID undefined." raise ValueError(error) @@ -47,6 +152,47 @@ def get_by_id(self, user_id: str) -> UserItem: # Update user @api(version="2.0") def update(self, user_item: UserItem, password: Optional[str] = None) -> UserItem: + """ + Modifies information about the specified user. + + If Tableau Server is configured to use local authentication, you can + update the user's name, email address, password, or site role. + + If Tableau Server is configured to use Active Directory + authentication, you can change the user's display name (full name), + email address, and site role. However, if you synchronize the user with + Active Directory, the display name and email address will be + overwritten with the information that's in Active Directory. + + For Tableau Cloud, you can update the site role for a user, but you + cannot update or change a user's password, user name (email address), + or full name. + + Parameters + ---------- + user_item : UserItem + The user item to update. + + password : Optional[str] + The new password for the user. + + Returns + ------- + UserItem + The user item that was updated. + + Raises + ------ + MissingRequiredFieldError + If the user item is missing an ID. + + Examples + -------- + >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + >>> user.fullname = 'New Full Name' + >>> updated_user = server.users.update(user) + + """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -61,6 +207,31 @@ def update(self, user_item: UserItem, password: Optional[str] = None) -> UserIte # Delete 1 user by id @api(version="2.0") def remove(self, user_id: str, map_assets_to: Optional[str] = None) -> None: + """ + Removes a user from the site. You can also specify a user to map the + assets to when you remove the user. + + Parameters + ---------- + user_id : str + The ID of the user to remove. + + map_assets_to : Optional[str] + The ID of the user to map the assets to when you remove the user. + + Returns + ------- + None + + Raises + ------ + ValueError + If the user ID is not specified. + + Examples + -------- + >>> server.users.remove('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + """ if not user_id: error = "User ID undefined." raise ValueError(error) @@ -73,6 +244,95 @@ def remove(self, user_id: str, map_assets_to: Optional[str] = None) -> None: # Add new user to site @api(version="2.0") def add(self, user_item: UserItem) -> UserItem: + """ + Adds the user to the site. + + To add a new user to the site you need to first create a new user_item + (from UserItem class). When you create a new user, you specify the name + of the user and their site role. For Tableau Cloud, you also specify + the auth_setting attribute in your request. When you add user to + Tableau Cloud, the name of the user must be the email address that is + used to sign in to Tableau Cloud. After you add a user, Tableau Cloud + sends the user an email invitation. The user can click the link in the + invitation to sign in and update their full name and password. + + Parameters + ---------- + user_item : UserItem + The user item to add to the site. + + Returns + ------- + UserItem + The user item that was added to the site with attributes from the + site populated. + + Raises + ------ + ValueError + If the user item is missing a name + + ValueError + If the user item is missing a site role + + ServerResponseError + code: 400000 + summary: Bad Request + detail: The content of the request body is missing or incomplete, or + contains malformed XML. + + ServerResponseError + code: 400003 + summary: Bad Request + detail: The user authentication setting ServerDefault is not + supported for you site. Try again using TableauIDWithMFA instead. + + ServerResponseError + code: 400013 + summary: Invalid site role + detail: The value of the siteRole attribute must be Explorer, + ExplorerCanPublish, SiteAdministratorCreator, + SiteAdministratorExplorer, Unlicensed, or Viewer. + + ServerResponseError + code: 404000 + summary: Site not found + detail: The site ID in the URI doesn't correspond to an existing site. + + ServerResponseError + code: 404002 + summary: User not found + detail: The server is configured to use Active Directory for + authentication, and the username specified in the request body + doesn't match an existing user in Active Directory. + + ServerResponseError + code: 405000 + summary: Invalid request method + detail: Request type was not POST. + + ServerResponseError + code: 409000 + summary: User conflict + detail: The specified user already exists on the site. + + ServerResponseError + code: 409005 + summary: Guest user conflict + detail: The Tableau Server API doesn't allow adding a user with the + guest role to a site. + + + Examples + -------- + >>> import tableauserverclient as TSC + >>> server = TSC.Server('https://SERVERURL') + >>> # Login to the server + + >>> new_user = TSC.UserItem(name='new_user', site_role=TSC.UserItem.Role.Unlicensed) + >>> new_user = server.users.add(new_user) + + """ url = self.baseurl logger.info(f"Add user {user_item.name}") add_req = RequestFactory.User.add_req(user_item) @@ -122,6 +382,42 @@ 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: + """ + Returns information about the workbooks that the specified user owns + and has Read (view) permissions for. + + This method retrieves the workbook information for the specified user. + The REST API is designed to return only the information you ask for + explicitly. When you query for all the users, the workbook information + for each user is not included. Use this method to retrieve information + about the workbooks that the user owns or has Read (view) permissions. + The method adds the list of workbooks to the user item object + (user_item.workbooks). + + Parameters + ---------- + user_item : UserItem + The user item to populate workbooks for. + + req_options : Optional[RequestOptions] + Optional request options to filter and sort the results. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the user item is missing an ID. + + Examples + -------- + >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + >>> server.users.populate_workbooks(user) + >>> for wb in user.workbooks: + >>> print(wb.name) + """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -142,11 +438,62 @@ def _get_wbs_for_user( return workbook_item, pagination_item def populate_favorites(self, user_item: UserItem) -> None: + """ + Populate the favorites for the user. + + Parameters + ---------- + user_item : UserItem + The user item to populate favorites for. + + Returns + ------- + None + + Examples + -------- + >>> import tableauserverclient as TSC + >>> server = TSC.Server('https://SERVERURL') + >>> # Login to the server + + >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + >>> server.users.populate_favorites(user) + >>> for obj_type, items in user.favorites.items(): + >>> print(f"Favorites for {obj_type}:") + >>> for item in items: + >>> print(item.name) + """ self.parent_srv.favorites.get(user_item) # Get groups for user @api(version="3.7") def populate_groups(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: + """ + Populate the groups for the user. + + Parameters + ---------- + user_item : UserItem + The user item to populate groups for. + + req_options : Optional[RequestOptions] + Optional request options to filter and sort the results. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the user item is missing an ID. + + Examples + -------- + >>> server.users.populate_groups(user) + >>> for group in user.groups: + >>> print(group.name) + """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 5e4442b6..460017d1 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -7,6 +7,7 @@ from pathlib import Path from tableauserverclient.helpers.headers import fix_filename +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.query import QuerySet from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in @@ -69,6 +70,22 @@ def baseurl(self) -> str: # Get all workbooks on site @api(version="2.0") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WorkbookItem], PaginationItem]: + """ + Queries the server and returns information about the workbooks the site. + + Parameters + ---------- + req_options : RequestOptions, optional + (Optional) You can pass the method a request object that contains + additional parameters to filter the request. For example, if you + were searching for a specific workbook, you could specify the name + of the workbook or the name of the owner. + + Returns + ------- + Tuple containing one page's worth of workbook items and pagination + information. + """ logger.info("Querying all workbooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -79,6 +96,19 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Work # Get 1 workbook @api(version="2.0") def get_by_id(self, workbook_id: str) -> WorkbookItem: + """ + Returns information about the specified workbook on the site. + + Parameters + ---------- + workbook_id : str + The workbook ID. + + Returns + ------- + WorkbookItem + The workbook item. + """ if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) @@ -89,6 +119,19 @@ def get_by_id(self, workbook_id: str) -> WorkbookItem: @api(version="2.8") def refresh(self, workbook_item: Union[WorkbookItem, str]) -> JobItem: + """ + Refreshes the extract of an existing workbook. + + Parameters + ---------- + workbook_item : WorkbookItem | str + The workbook item or workbook ID. + + Returns + ------- + JobItem + The job item. + """ id_ = getattr(workbook_item, "id", workbook_item) url = f"{self.baseurl}/{id_}/refresh" empty_req = RequestFactory.Empty.empty_req() @@ -105,6 +148,33 @@ def create_extract( includeAll: bool = True, datasources: Optional[list["DatasourceItem"]] = None, ) -> JobItem: + """ + Create one or more extracts on 1 workbook, optionally encrypted. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_extracts_for_workbook + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to create extracts for. + + encrypt : bool, default False + Set to True to encrypt the extracts. + + includeAll : bool, default True + If True, all data sources in the workbook will have an extract + created for them. If False, then a data source must be supplied in + the request. + + datasources : list[DatasourceItem] | None + List of DatasourceItem objects for the data sources to create + extracts for. Only required if includeAll is False. + + Returns + ------- + JobItem + The job item for the extract creation. + """ id_ = getattr(workbook_item, "id", workbook_item) url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}" @@ -116,6 +186,29 @@ def create_extract( # delete all the extracts on 1 workbook @api(version="3.3") def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True, datasources=None) -> JobItem: + """ + Delete all extracts of embedded datasources on 1 workbook. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_extracts_from_workbook + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to delete extracts from. + + includeAll : bool, default True + If True, all data sources in the workbook will have their extracts + deleted. If False, then a data source must be supplied in the + request. + + datasources : list[DatasourceItem] | None + List of DatasourceItem objects for the data sources to delete + extracts from. Only required if includeAll is False. + + Returns + ------- + JobItem + """ id_ = getattr(workbook_item, "id", workbook_item) url = f"{self.baseurl}/{id_}/deleteExtract" datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) @@ -126,6 +219,18 @@ def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True, d # Delete 1 workbook by id @api(version="2.0") def delete(self, workbook_id: str) -> None: + """ + Deletes a workbook with the specified ID. + + Parameters + ---------- + workbook_id : str + The workbook ID. + + Returns + ------- + None + """ if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) @@ -141,6 +246,29 @@ def update( workbook_item: WorkbookItem, include_view_acceleration_status: bool = False, ) -> WorkbookItem: + """ + Modifies an existing workbook. Use this method to change the owner or + the project that the workbook belongs to, or to change whether the + workbook shows views in tabs. The workbook item must include the + workbook ID and overrides the existing settings. + + See https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#update_workbook + for a list of fields that can be updated. + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to update. ID is required. Other fields are + optional. Any fields that are not specified will not be changed. + + include_view_acceleration_status : bool, default False + Set to True to include the view acceleration status in the response. + + Returns + ------- + WorkbookItem + The updated workbook item. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -161,6 +289,28 @@ def update( # Update workbook_connection @api(version="2.3") def update_connection(self, workbook_item: WorkbookItem, connection_item: ConnectionItem) -> ConnectionItem: + """ + Updates a workbook connection information (server addres, server port, + user name, and password). + + The workbook connections must be populated before the strings can be + updated. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#update_workbook_connection + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to update. + + connection_item : ConnectionItem + The connection item to update. + + Returns + ------- + ConnectionItem + The updated connection item. + """ url = f"{self.baseurl}/{workbook_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) @@ -179,6 +329,34 @@ def download( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: + """ + Downloads a workbook to the specified directory (optional). + + Parameters + ---------- + workbook_id : str + The workbook ID. + + filepath : Path or File object, optional + Downloads the file to the location you specify. If no location is + specified, the file is downloaded to the current working directory. + The default is Filepath=None. + + include_extract : bool, default True + Set to False to exclude the extract from the download. The default + is True. + + Returns + ------- + Path or File object + The path to the downloaded workbook or the file object. + + Raises + ------ + ValueError + If the workbook ID is not defined. + """ + return self.download_revision( workbook_id, None, @@ -189,6 +367,36 @@ def download( # Get all views of workbook @api(version="2.0") def populate_views(self, workbook_item: WorkbookItem, usage: bool = False) -> None: + """ + Populates (or gets) a list of views for a workbook. + + You must first call this method to populate views before you can iterate + through the views. + + This method retrieves the view information for the specified workbook. + The REST API is designed to return only the information you ask for + explicitly. When you query for all the workbooks, the view information + is not included. Use this method to retrieve the views. The method adds + the list of views to the workbook item (workbook_item.views). This is a + list of ViewItem. + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate views for. + + usage : bool, default False + Set to True to include usage statistics for each view. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -214,6 +422,36 @@ def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> l # Get all connections of workbook @api(version="2.0") def populate_connections(self, workbook_item: WorkbookItem) -> None: + """ + Populates a list of data source connections for the specified workbook. + + You must populate connections before you can iterate through the + connections. + + This method retrieves the data source connection information for the + specified workbook. The REST API is designed to return only the + information you ask for explicitly. When you query all the workbooks, + the data source connection information is not included. Use this method + to retrieve the connection information for any data sources used by the + workbook. The method adds the list of data connections to the workbook + item (workbook_item.connections). This is a list of ConnectionItem. + + REST API docs: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_workbook_connections + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate connections for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -235,6 +473,34 @@ def _get_workbook_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: + """ + Populates the PDF for the specified workbook item. + + This method populates a PDF with image(s) of the workbook view(s) you + specify. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#download_workbook_pdf + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate the PDF for. + + req_options : RequestOptions, 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 + details. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID." raise MissingRequiredFieldError(error) @@ -253,6 +519,36 @@ def _get_wb_pdf(self, workbook_item: WorkbookItem, req_options: Optional["Reques @api(version="3.8") def populate_powerpoint(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: + """ + Populates the PowerPoint for the specified workbook item. + + This method populates a PowerPoint with image(s) of the workbook view(s) you + specify. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#download_workbook_powerpoint + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate the PDF for. + + req_options : RequestOptions, optional + (Optional) You can pass in request options to specify the maximum + number of minutes a workbook .pptx will be cached before being + refreshed. To prevent multiple .pptx requests from overloading the + server, the shortest interval you can set is one minute. There is no + maximum value, but the server job enacting the caching action may + expire before a long cache period is reached. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID." raise MissingRequiredFieldError(error) @@ -272,6 +568,26 @@ def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["Reque # Get preview image of workbook @api(version="2.0") def populate_preview_image(self, workbook_item: WorkbookItem) -> None: + """ + This method gets the preview image (thumbnail) for the specified workbook item. + + This method uses the workbook's ID to get the preview image. The method + adds the preview image to the workbook item (workbook_item.preview_image). + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate the preview image for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -290,14 +606,65 @@ def _get_wb_preview_image(self, workbook_item: WorkbookItem) -> bytes: @api(version="2.0") def populate_permissions(self, item: WorkbookItem) -> None: + """ + Populates the permissions for the specified workbook item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_workbook_permissions + + Parameters + ---------- + item : WorkbookItem + The workbook item to populate permissions for. + + Returns + ------- + None + """ self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, resource, rules): + def update_permissions(self, resource: WorkbookItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + """ + Updates the permissions for the specified workbook item. The method + replaces the existing permissions with the new permissions. Any missing + permissions are removed. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_permissions_for_content + + Parameters + ---------- + resource : WorkbookItem + The workbook item to update permissions for. + + rules : list[PermissionsRule] + A list of permissions rules to apply to the workbook item. + + Returns + ------- + list[PermissionsRule] + The updated permissions rules. + """ return self._permissions.update(resource, rules) @api(version="2.0") - def delete_permission(self, item, capability_item): + def delete_permission(self, item: WorkbookItem, capability_item: PermissionsRule) -> None: + """ + Deletes a single permission rule from the specified workbook item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_workbook_permission + + Parameters + ---------- + item : WorkbookItem + The workbook item to delete the permission from. + + capability_item : PermissionsRule + The permission rule to delete. + + Returns + ------- + None + """ return self._permissions.delete(item, capability_item) @api(version="2.0") @@ -313,6 +680,83 @@ def publish( skip_connection_check: bool = False, parameters=None, ): + """ + Publish a workbook to the specified site. + + Note: The REST API cannot automatically include extracts or other + resources that the workbook uses. Therefore, a .twb file that uses data + from an Excel or csv file on a local computer cannot be published, + unless you package the data and workbook in a .twbx file, or publish the + data source separately. + + For workbooks that are larger than 64 MB, the publish method + automatically takes care of chunking the file in parts for uploading. + Using this method is considerably more convenient than calling the + publish REST APIs directly. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#publish_workbook + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook_item specifies the workbook you are publishing. When + you are adding a workbook, you need to first create a new instance + of a workbook_item that includes a project_id of an existing + project. The name of the workbook will be the name of the file, + unless you also specify a name for the new workbook when you create + the instance. + + file : Path or File object + The file path or file object of the workbook to publish. When + providing a file object, you must also specifiy the name of the + workbook in your instance of the workbook_itemworkbook_item , as + the name cannot be derived from the file name. + + mode : str + Specifies whether you are publishing a new workbook (CreateNew) or + overwriting an existing workbook (Overwrite). You cannot appending + workbooks. You can also use the publish mode attributes, for + example: TSC.Server.PublishMode.Overwrite. + + connections : list[ConnectionItem] | None + List of ConnectionItems objects for the connections created within + the workbook. + + as_job : bool, default False + Set to True to run the upload as a job (asynchronous upload). If set + to True a job will start to perform the publishing process and a Job + object is returned. Defaults to False. + + skip_connection_check : bool, default False + Set to True to skip connection check at time of upload. Publishing + will succeed but unchecked connection issues may result in a + non-functioning workbook. Defaults to False. + + Raises + ------ + OSError + If the file path does not lead to an existing file. + + ServerResponseError + If the server response is not successful. + + TypeError + If the file is not a file path or file object. + + ValueError + If the file extension is not supported + + ValueError + If the mode is invalid. + + ValueError + Workbooks cannot be appended. + + Returns + ------- + WorkbookItem | JobItem + The workbook item or job item that was published. + """ if isinstance(file, (str, os.PathLike)): if not os.path.isfile(file): error = "File path does not lead to an existing file." @@ -419,6 +863,28 @@ def publish( # Populate workbook item's revisions @api(version="2.3") def populate_revisions(self, workbook_item: WorkbookItem) -> None: + """ + Populates (or gets) a list of revisions for a workbook. + + You must first call this method to populate revisions before you can + iterate through the revisions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_workbook_revisions + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate revisions for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -446,6 +912,40 @@ def download_revision( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: + """ + Downloads a workbook revision to the specified directory (optional). + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#download_workbook_revision + + Parameters + ---------- + workbook_id : str + The workbook ID. + + revision_number : str | None + The revision number of the workbook. If None, the latest revision is + downloaded. + + filepath : Path or File object, optional + Downloads the file to the location you specify. If no location is + specified, the file is downloaded to the current working directory. + The default is Filepath=None. + + include_extract : bool, default True + Set to False to exclude the extract from the download. The default + is True. + + Returns + ------- + Path or File object + The path to the downloaded workbook or the file object. + + Raises + ------ + ValueError + If the workbook ID is not defined. + """ + if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) @@ -479,6 +979,28 @@ def download_revision( @api(version="2.3") def delete_revision(self, workbook_id: str, revision_number: str) -> None: + """ + Deletes a specific revision from a workbook on Tableau Server. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_revisions.htm#remove_workbook_revision + + Parameters + ---------- + workbook_id : str + The workbook ID. + + revision_number : str + The revision number of the workbook to delete. + + Returns + ------- + None + + Raises + ------ + ValueError + If the workbook ID or revision number is not defined. + """ if workbook_id is None or revision_number is None: raise ValueError url = "/".join([self.baseurl, workbook_id, "revisions", revision_number]) @@ -491,18 +1013,90 @@ def delete_revision(self, workbook_id: str, revision_number: str) -> None: def schedule_extract_refresh( self, schedule_id: str, item: WorkbookItem ) -> list["AddResponse"]: # actually should return a task + """ + Adds a workbook to a schedule for extract refresh. + + 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 + + Parameters + ---------- + schedule_id : str + The schedule ID. + + item : WorkbookItem + The workbook item to add to the schedule. + + Returns + ------- + list[AddResponse] + The response from the server. + """ return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) @api(version="1.0") def add_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> set[str]: + """ + Adds tags to a workbook. One or more tags may be added at a time. If a + tag already exists on the workbook, it will not be duplicated. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#add_tags_to_workbook + + Parameters + ---------- + item : WorkbookItem | str + The workbook item or workbook ID to add tags to. + + tags : Iterable[str] | str + The tag or tags to add to the workbook. Tags can be a single tag or + a list of tags. + + Returns + ------- + set[str] + The set of tags added to the workbook. + """ return super().add_tags(item, tags) @api(version="1.0") def delete_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> None: + """ + Deletes tags from a workbook. One or more tags may be deleted at a time. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#delete_tag_from_workbook + + Parameters + ---------- + item : WorkbookItem | str + The workbook item or workbook ID to delete tags from. + + tags : Iterable[str] | str + The tag or tags to delete from the workbook. Tags can be a single + tag or a list of tags. + + Returns + ------- + None + """ return super().delete_tags(item, tags) @api(version="1.0") def update_tags(self, item: WorkbookItem) -> None: + """ + Updates the tags on a workbook. This method is used to update the tags + on the server to match the tags on the workbook item. This method is a + convenience method that calls add_tags and delete_tags to update the + tags on the server. + + Parameters + ---------- + item : WorkbookItem + The workbook item to update the tags for. The tags on the workbook + item will be used to update the tags on the server. + + Returns + ------- + None + """ return super().update_tags(item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[WorkbookItem]: diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index feebc1a7..801ad4a1 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -77,6 +77,7 @@ def __iter__(self: Self) -> Iterator[T]: for page in count(1): self.request_options.pagenumber = page self._result_cache = [] + self._pagination_item._page_number = None try: self._fetch_all() except ServerResponseError as e: @@ -85,6 +86,8 @@ def __iter__(self: Self) -> Iterator[T]: # up overrunning the total number of pages. Catch the # error and break out of the loop. raise StopIteration + if len(self._result_cache) == 0: + return yield from self._result_cache # If the length of the QuerySet is unknown, continue fetching until # the result cache is empty. @@ -139,6 +142,7 @@ def __getitem__(self, k): elif k in range(self.total_available): # Otherwise, check if k is even sensible to return self._result_cache = [] + self._pagination_item._page_number = None # Add one to k, otherwise it gets stuck at page boundaries, e.g. 100 self.request_options.pagenumber = max(1, math.ceil((k + 1) / size)) return self[k] @@ -150,7 +154,7 @@ def _fetch_all(self: Self) -> None: """ Retrieve the data and store result and pagination item in cache """ - if not self._result_cache: + if not self._result_cache and self._pagination_item._page_number is None: response = self.model.get(self.request_options) if isinstance(response, tuple): self._result_cache, self._pagination_item = response @@ -159,7 +163,7 @@ def _fetch_all(self: Self) -> None: self._pagination_item = PaginationItem() def __len__(self: Self) -> int: - return self.total_available or sys.maxsize + return sys.maxsize if self.total_available is None else self.total_available @property def total_available(self: Self) -> int: diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index f7bd139d..e99df710 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -996,9 +996,9 @@ def update_req(self, workbook_item): if data_freshness_policy_config.option == "FreshEvery": if data_freshness_policy_config.fresh_every_schedule is not None: fresh_every_element = ET.SubElement(data_freshness_policy_element, "freshEverySchedule") - fresh_every_element.attrib["frequency"] = ( - data_freshness_policy_config.fresh_every_schedule.frequency - ) + fresh_every_element.attrib[ + "frequency" + ] = data_freshness_policy_config.fresh_every_schedule.frequency fresh_every_element.attrib["value"] = str(data_freshness_policy_config.fresh_every_schedule.value) else: raise ValueError(f"data_freshness_policy_config.fresh_every_schedule must be populated.") @@ -1199,11 +1199,13 @@ def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt # Schedule element schedule_element = ET.SubElement(subscription_element, "schedule") - schedule_element.attrib["id"] = subscription_item.schedule_id + if subscription_item.schedule_id is not None: + schedule_element.attrib["id"] = subscription_item.schedule_id # User element user_element = ET.SubElement(subscription_element, "user") - user_element.attrib["id"] = subscription_item.user_id + if subscription_item.user_id is not None: + user_element.attrib["id"] = subscription_item.user_id return ET.tostring(xml_request) @_tsrequest_wrapped diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index a3ad0c49..d79ac7f7 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,4 +1,5 @@ import sys +from typing import Optional from typing_extensions import Self @@ -26,11 +27,48 @@ def apply_query_params(self, url): except NotImplementedError: raise - def get_query_params(self): - raise NotImplementedError() + +# If it wasn't a breaking change, I'd rename it to QueryOptions +""" +This class manages options can be used when querying content on the server +""" class RequestOptions(RequestOptionsBase): + def __init__(self, pagenumber=1, pagesize=None): + self.pagenumber = pagenumber + self.pagesize = pagesize or config.PAGE_SIZE + self.sort = set() + self.filter = set() + # This is private until we expand all of our parsers to handle the extra fields + self._all_fields = False + + def get_query_params(self) -> dict: + params = {} + if self.sort and len(self.sort) > 0: + sort_options = (str(sort_item) for sort_item in self.sort) + ordered_sort_options = sorted(sort_options) + params["sort"] = ",".join(ordered_sort_options) + if len(self.filter) > 0: + 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: + params["fields"] = "_all_" + if self.pagenumber: + params["pageNumber"] = self.pagenumber + if self.pagesize: + params["pageSize"] = self.pagesize + return params + + def page_size(self, page_size): + self.pagesize = page_size + return self + + def page_number(self, page_number): + self.pagenumber = page_number + return self + class Operator: Equals = "eq" GreaterThan = "gt" @@ -41,6 +79,7 @@ class Operator: Has = "has" CaseInsensitiveEquals = "cieq" + # These are fields in the REST API class Field: Args = "args" AuthenticationType = "authenticationType" @@ -117,51 +156,43 @@ class Direction: Desc = "desc" Asc = "asc" - def __init__(self, pagenumber=1, pagesize=None): - self.pagenumber = pagenumber - self.pagesize = pagesize or config.PAGE_SIZE - self.sort = set() - self.filter = set() - - # This is private until we expand all of our parsers to handle the extra fields - self._all_fields = False - def page_size(self, page_size): - self.pagesize = page_size - return self +""" +These options can be used by methods that are fetching data exported from a specific content item +""" - def page_number(self, page_number): - self.pagenumber = page_number - return self - - def get_query_params(self): - params = {} - if self.pagenumber: - params["pageNumber"] = self.pagenumber - if self.pagesize: - params["pageSize"] = self.pagesize - if len(self.sort) > 0: - sort_options = (str(sort_item) for sort_item in self.sort) - ordered_sort_options = sorted(sort_options) - params["sort"] = ",".join(ordered_sort_options) - if len(self.filter) > 0: - 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: - params["fields"] = "_all_" - return params +class _DataExportOptions(RequestOptionsBase): + def __init__(self, maxage: int = -1): + super().__init__() + self.view_filters: list[tuple[str, str]] = [] + self.view_parameters: list[tuple[str, str]] = [] + self.max_age: Optional[int] = maxage + """ + This setting will affect the contents of the workbook as they are exported. + Valid language values are tableau-supported languages like de, es, en + If no locale is specified, the default locale for that language will be used + """ + self.language: Optional[str] = None -class _FilterOptionsBase(RequestOptionsBase): - """Provide a basic implementation of adding view filters to the url""" + @property + def max_age(self) -> int: + return self._max_age - def __init__(self): - self.view_filters = [] - self.view_parameters = [] + @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): - raise NotImplementedError() + params = {} + if self.max_age != -1: + params["maxAge"] = self.max_age + if self.language: + params["language"] = self.language + + self._append_view_filters(params) + return params def vf(self, name: str, value: str) -> Self: """Apply a filter based on a column within the view. @@ -182,82 +213,73 @@ def _append_view_filters(self, params) -> None: params[name] = value -class CSVRequestOptions(_FilterOptionsBase): - def __init__(self, maxage=-1): - super().__init__() - self.max_age = maxage +class _ImagePDFCommonExportOptions(_DataExportOptions): + def __init__(self, maxage=-1, viz_height=None, viz_width=None): + super().__init__(maxage=maxage) + self.viz_height = viz_height + self.viz_width = viz_width @property - def max_age(self): - 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 viz_height(self): + return self._viz_height - def get_query_params(self): - params = {} - if self.max_age != -1: - params["maxAge"] = self.max_age + @viz_height.setter + @property_is_int(range=(0, sys.maxsize), allowed=(None,)) + def viz_height(self, value): + self._viz_height = value - self._append_view_filters(params) - return params + @property + def viz_width(self): + return self._viz_width + @viz_width.setter + @property_is_int(range=(0, sys.maxsize), allowed=(None,)) + def viz_width(self, value): + self._viz_width = value -class ExcelRequestOptions(_FilterOptionsBase): - def __init__(self, maxage: int = -1) -> None: - super().__init__() - self.max_age = maxage + def get_query_params(self) -> dict: + params = super().get_query_params() - @property - def max_age(self) -> int: - return self._max_age + # XOR. Either both are None or both are not None. + if (self.viz_height is None) ^ (self.viz_width is None): + raise ValueError("viz_height and viz_width must be specified together") - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value: int) -> None: - self._max_age = value + if self.viz_height is not None: + params["vizHeight"] = self.viz_height - def get_query_params(self): - params = {} - if self.max_age != -1: - params["maxAge"] = self.max_age + if self.viz_width is not None: + params["vizWidth"] = self.viz_width - self._append_view_filters(params) return params -class ImageRequestOptions(_FilterOptionsBase): +class CSVRequestOptions(_DataExportOptions): + extension = "csv" + + +class ExcelRequestOptions(_DataExportOptions): + extension = "xlsx" + + +class ImageRequestOptions(_ImagePDFCommonExportOptions): + extension = "png" + # if 'high' isn't specified, the REST API endpoint returns an image with standard resolution class Resolution: High = "high" - def __init__(self, imageresolution=None, maxage=-1): - super().__init__() + def __init__(self, imageresolution=None, maxage=-1, viz_height=None, viz_width=None): + super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width) self.image_resolution = imageresolution - self.max_age = maxage - - @property - def max_age(self): - 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 = {} + params = super().get_query_params() if self.image_resolution: params["resolution"] = self.image_resolution - if self.max_age != -1: - params["maxAge"] = self.max_age - self._append_view_filters(params) return params -class PDFRequestOptions(_FilterOptionsBase): +class PDFRequestOptions(_ImagePDFCommonExportOptions): class PageType: A3 = "a3" A4 = "a4" @@ -279,61 +301,16 @@ class Orientation: Landscape = "landscape" def __init__(self, page_type=None, orientation=None, maxage=-1, viz_height=None, viz_width=None): - super().__init__() + super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width) self.page_type = page_type self.orientation = orientation - self.max_age = maxage - self.viz_height = viz_height - self.viz_width = viz_width - - @property - def max_age(self): - return self._max_age - - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value): - self._max_age = value - @property - def viz_height(self): - return self._viz_height - - @viz_height.setter - @property_is_int(range=(0, sys.maxsize), allowed=(None,)) - def viz_height(self, value): - self._viz_height = value - - @property - def viz_width(self): - return self._viz_width - - @viz_width.setter - @property_is_int(range=(0, sys.maxsize), allowed=(None,)) - def viz_width(self, value): - self._viz_width = value - - def get_query_params(self): - params = {} + def get_query_params(self) -> dict: + params = super().get_query_params() if self.page_type: params["type"] = self.page_type if self.orientation: params["orientation"] = self.orientation - if self.max_age != -1: - params["maxAge"] = self.max_age - - # XOR. Either both are None or both are not None. - if (self.viz_height is None) ^ (self.viz_width is None): - raise ValueError("viz_height and viz_width must be specified together") - - if self.viz_height is not None: - params["vizHeight"] = self.viz_height - - if self.viz_width is not None: - params["vizWidth"] = self.viz_width - - self._append_view_filters(params) - return params diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index dab5911d..4eeefcaf 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -59,7 +59,63 @@ class Server: + """ + In the Tableau REST API, the server (https://MY-SERVER/) is the base or core + of the URI that makes up the various endpoints or methods for accessing + resources on the server (views, workbooks, sites, users, data sources, etc.) + The TSC library provides a Server class that represents the server. You + create a server instance to sign in to the server and to call the various + methods for accessing resources. + + The Server class contains the attributes that represent the server on + Tableau Server. After you create an instance of the Server class, you can + sign in to the server and call methods to access all of the resources on the + server. + + Parameters + ---------- + server_address : str + Specifies the address of the Tableau Server or Tableau Cloud (for + example, https://MY-SERVER/). + + use_server_version : bool + Specifies the version of the REST API to use (for example, '2.5'). When + you use the TSC library to call methods that access Tableau Server, the + version is passed to the endpoint as part of the URI + (https://MY-SERVER/api/2.5/). Each release of Tableau Server supports + specific versions of the REST API. New versions of the REST API are + released with Tableau Server. By default, the value of version is set to + '2.3', which corresponds to Tableau Server 10.0. You can view or set + this value. You might need to set this to a different value, for + example, if you want to access features that are supported by the server + and a later version of the REST API. For more information, see REST API + Versions. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create a instance of server + >>> server = TSC.Server('https://MY-SERVER') + + >>> # sign in, etc. + + >>> # change the REST API version to match the server + >>> server.use_server_version() + + >>> # or change the REST API version to match a specific version + >>> # for example, 2.8 + >>> # server.version = '2.8' + + """ + class PublishMode: + """ + Enumerates the options that specify what happens when you publish a + workbook or data source. The options are Overwrite, Append, or + CreateNew. + """ + Append = "Append" Overwrite = "Overwrite" CreateNew = "CreateNew" diff --git a/test/assets/server_info_wrong_site.html b/test/assets/server_info_wrong_site.html new file mode 100644 index 00000000..e92daeb2 --- /dev/null +++ b/test/assets/server_info_wrong_site.html @@ -0,0 +1,56 @@ + + + + + + Example website + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ABCDE
12345
23456
34567
45678
56789
+ + + \ No newline at end of file diff --git a/test/test_auth.py b/test/test_auth.py index eaf13481..48100ad8 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -63,7 +63,7 @@ def test_sign_in_error(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("testuser", "wrongpassword") - self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_invalid_token(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -71,7 +71,7 @@ def test_sign_in_invalid_token(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.PersonalAccessTokenAuth(token_name="mytoken", personal_access_token="invalid") - self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_without_auth(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -79,7 +79,7 @@ def test_sign_in_without_auth(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("", "") - self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) def test_sign_out(self): with open(SIGN_IN_XML, "rb") as f: diff --git a/test/test_custom_view.py b/test/test_custom_view.py index 80800c86..6e863a86 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -18,6 +18,8 @@ GET_XML_ID = os.path.join(TEST_ASSET_DIR, "custom_view_get_id.xml") POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, "Sample View Image.png") CUSTOM_VIEW_UPDATE_XML = os.path.join(TEST_ASSET_DIR, "custom_view_update.xml") +CUSTOM_VIEW_POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf") +CUSTOM_VIEW_POPULATE_CSV = os.path.join(TEST_ASSET_DIR, "populate_csv.csv") CUSTOM_VIEW_DOWNLOAD = TEST_ASSET_DIR / "custom_view_download.json" FILE_UPLOAD_INIT = TEST_ASSET_DIR / "fileupload_initialize.xml" FILE_UPLOAD_APPEND = TEST_ASSET_DIR / "fileupload_append.xml" @@ -246,3 +248,73 @@ def test_large_publish(self): assert isinstance(view, TSC.CustomViewItem) assert view.id is not None assert view.name is not None + + def test_populate_pdf(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.custom_views.baseurl + with open(CUSTOM_VIEW_POPULATE_PDF, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get( + self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?type=letter&orientation=portrait&maxAge=5", + content=response, + ) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + size = TSC.PDFRequestOptions.PageType.Letter + orientation = TSC.PDFRequestOptions.Orientation.Portrait + req_option = TSC.PDFRequestOptions(size, orientation, 5) + + self.server.custom_views.populate_pdf(custom_view, req_option) + self.assertEqual(response, custom_view.pdf) + + def test_populate_csv(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.custom_views.baseurl + with open(CUSTOM_VIEW_POPULATE_CSV, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data?maxAge=1", content=response) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + request_option = TSC.CSVRequestOptions(maxage=1) + self.server.custom_views.populate_csv(custom_view, request_option) + + csv_file = b"".join(custom_view.csv) + self.assertEqual(response, csv_file) + + def test_populate_csv_default_maxage(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.custom_views.baseurl + with open(CUSTOM_VIEW_POPULATE_CSV, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data", content=response) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + self.server.custom_views.populate_csv(custom_view) + + csv_file = b"".join(custom_view.csv) + self.assertEqual(response, csv_file) + + def test_pdf_height(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.custom_views.baseurl + with open(CUSTOM_VIEW_POPULATE_PDF, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get( + self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?vizHeight=1080&vizWidth=1920", + content=response, + ) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + req_option = TSC.PDFRequestOptions( + viz_height=1080, + viz_width=1920, + ) + + self.server.custom_views.populate_pdf(custom_view, req_option) + self.assertEqual(response, custom_view.pdf) diff --git a/test/test_pager.py b/test/test_pager.py index c3035280..1836095b 100644 --- a/test/test_pager.py +++ b/test/test_pager.py @@ -1,6 +1,7 @@ import contextlib import os import unittest +import xml.etree.ElementTree as ET import requests_mock @@ -122,3 +123,14 @@ def test_pager_view(self) -> None: m.get(self.server.views.baseurl, text=view_xml) for view in TSC.Pager(self.server.views): assert view.name is not None + + def test_queryset_no_matches(self) -> None: + elem = ET.Element("tsResponse", xmlns="http://tableau.com/api") + ET.SubElement(elem, "pagination", totalAvailable="0") + ET.SubElement(elem, "groups") + xml = ET.tostring(elem).decode("utf-8") + with requests_mock.mock() as m: + m.get(self.server.groups.baseurl, text=xml) + all_groups = self.server.groups.all() + groups = list(all_groups) + assert len(groups) == 0 diff --git a/test/test_request_option.py b/test/test_request_option.py index 9ca9779a..7405189a 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -358,3 +358,13 @@ def test_queryset_pagesize_filter(self) -> None: queryset = self.server.views.all().filter(page_size=page_size) assert queryset.request_options.pagesize == page_size _ = list(queryset) + + def test_language_export(self) -> None: + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = self.baseurl + "/views/456/data" + opts = TSC.PDFRequestOptions() + opts.language = "en-US" + + resp = self.server.users.get_request(url, request_object=opts) + self.assertTrue(re.search("language=en-us", resp.request.query)) diff --git a/test/test_server_info.py b/test/test_server_info.py index 1cf190ec..fa1472c9 100644 --- a/test/test_server_info.py +++ b/test/test_server_info.py @@ -4,6 +4,7 @@ import requests_mock import tableauserverclient as TSC +from tableauserverclient.server.endpoint.exceptions import NonXMLResponseError TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -11,6 +12,7 @@ SERVER_INFO_25_XML = os.path.join(TEST_ASSET_DIR, "server_info_25.xml") SERVER_INFO_404 = os.path.join(TEST_ASSET_DIR, "server_info_404.xml") SERVER_INFO_AUTH_INFO_XML = os.path.join(TEST_ASSET_DIR, "server_info_auth_info.xml") +SERVER_INFO_WRONG_SITE = os.path.join(TEST_ASSET_DIR, "server_info_wrong_site.html") class ServerInfoTests(unittest.TestCase): @@ -63,3 +65,11 @@ def test_server_use_server_version_flag(self): m.get("http://test/api/2.4/serverInfo", text=si_response_xml) server = TSC.Server("http://test", use_server_version=True) self.assertEqual(server.version, "2.5") + + def test_server_wrong_site(self): + with open(SERVER_INFO_WRONG_SITE, "rb") as f: + response = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.server.server_info.baseurl, text=response, status_code=404) + with self.assertRaises(NonXMLResponseError): + self.server.server_info.get()