From 6798b9e5fc27d459fea93b415519331c7adac954 Mon Sep 17 00:00:00 2001 From: renoyjohnm <168143499+renoyjohnm@users.noreply.github.com> Date: Wed, 23 Oct 2024 22:07:23 -0700 Subject: [PATCH 1/2] 0.34 development merge * Feature: export custom views #999 (#1506) * feat(exceptions): separate failed signin error (#1478) * refactor request_options, add language param (#1481) * Set FILESIZE_LIMIT_MB via environment variables (#1466) * added PulseMetricDefine cap (#1490) * Adding project permissions handling for databases, tables and virtual connections (#1482) * fix: queryset support for flowruns (#1460) * fix: set unknown size to sys.maxsize * fix: handle 0 item response in querysets (#1501) * chore: support VizqlDataApiAccess capability (#1504) * refactor(test): extract error factory to _utils * chore(typing): flowruns.cancel can also accept a FlowRunItem * chore: type hint default permissions endpoints (#1493) * chore(versions): update remaining f-strings (#1477) * Make urllib3 dependency more flexible (#1468) * Update requests library for CVE CVE-2024-35195 (#1507) * chore(versions): Upgrade minimum python version (#1465) * ci: cache dependencies for faster builds (#1497) * ci: build on python 3.13 (#1492) * Update samples for Python 3.x compatibility (#1479) * chore: remove py2 holdover code (#1496) * #Add 'description' to datasource sample code (#1475) * Remove sample code showing group name encoding (#1486) * chore(typing): include samples in type checks (#1455) * fix: docstring on QuerySet * docs: add docstrings to auth objects and endpoints (#1484) * docs: docstrings for Server and ServerInfo (#1494) * docs: docstrings for user item and endpoint (#1485) * docs: docstrings for site item and endpoint (#1495) * docs: workbook docstrings (#1488) * #1464 - docs update for filtering on boolean values (#1471) --------- Co-authored-by: Brian Cantoni Co-authored-by: Jordan Woods Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Co-authored-by: Jac Co-authored-by: Henning Merklinger Co-authored-by: AlbertWangXu Co-authored-by: TrimPeachu <77048868+TrimPeachu@users.noreply.github.com> --- .github/workflows/meta-checks.yml | 14 + .github/workflows/run-tests.yml | 16 +- pyproject.toml | 18 +- samples/add_default_permission.py | 4 +- samples/create_group.py | 13 +- samples/create_project.py | 2 +- samples/create_schedules.py | 8 +- samples/explore_datasource.py | 22 +- samples/explore_favorites.py | 16 +- samples/explore_site.py | 2 +- samples/explore_webhooks.py | 4 +- samples/explore_workbook.py | 33 +- samples/export.py | 23 +- samples/extracts.py | 14 +- samples/filter_sort_groups.py | 44 +- samples/filter_sort_projects.py | 2 +- samples/getting_started/1_hello_server.py | 4 +- samples/getting_started/2_hello_site.py | 4 +- samples/getting_started/3_hello_universe.py | 22 +- samples/initialize_server.py | 10 +- samples/list.py | 5 +- samples/login.py | 25 +- samples/move_workbook_sites.py | 8 +- samples/pagination_sample.py | 8 +- samples/publish_datasource.py | 25 +- samples/publish_workbook.py | 4 +- samples/query_permissions.py | 8 +- samples/refresh_tasks.py | 4 +- samples/set_refresh_schedule.py | 2 +- samples/update_connection.py | 2 +- samples/update_workbook_data_acceleration.py | 109 --- .../update_workbook_data_freshness_policy.py | 2 +- tableauserverclient/__init__.py | 50 +- tableauserverclient/_version.py | 18 +- tableauserverclient/config.py | 8 +- tableauserverclient/models/column_item.py | 2 +- .../models/connection_credentials.py | 2 +- tableauserverclient/models/connection_item.py | 12 +- .../models/custom_view_item.py | 35 +- .../models/data_acceleration_report_item.py | 4 +- tableauserverclient/models/data_alert_item.py | 10 +- .../models/data_freshness_policy_item.py | 12 +- tableauserverclient/models/database_item.py | 6 +- tableauserverclient/models/datasource_item.py | 20 +- tableauserverclient/models/dqw_item.py | 2 +- tableauserverclient/models/favorites_item.py | 11 +- tableauserverclient/models/fileupload_item.py | 2 +- tableauserverclient/models/flow_item.py | 12 +- tableauserverclient/models/flow_run_item.py | 6 +- tableauserverclient/models/group_item.py | 8 +- tableauserverclient/models/groupset_item.py | 8 +- tableauserverclient/models/interval_item.py | 18 +- tableauserverclient/models/job_item.py | 16 +- .../models/linked_tasks_item.py | 10 +- tableauserverclient/models/metric_item.py | 10 +- tableauserverclient/models/pagination_item.py | 2 +- .../models/permissions_item.py | 22 +- tableauserverclient/models/project_item.py | 54 +- .../models/property_decorators.py | 23 +- tableauserverclient/models/reference_item.py | 4 +- tableauserverclient/models/revision_item.py | 6 +- tableauserverclient/models/schedule_item.py | 4 +- .../models/server_info_item.py | 32 +- tableauserverclient/models/site_item.py | 72 +- .../models/subscription_item.py | 6 +- tableauserverclient/models/table_item.py | 2 +- tableauserverclient/models/tableau_auth.py | 120 ++- tableauserverclient/models/tableau_types.py | 4 +- tableauserverclient/models/tag_item.py | 7 +- tableauserverclient/models/task_item.py | 8 +- tableauserverclient/models/user_item.py | 64 +- tableauserverclient/models/view_item.py | 21 +- .../models/virtual_connection_item.py | 11 +- tableauserverclient/models/webhook_item.py | 12 +- tableauserverclient/models/workbook_item.py | 102 ++- tableauserverclient/namespace.py | 2 +- tableauserverclient/server/__init__.py | 3 +- .../server/endpoint/auth_endpoint.py | 73 +- .../server/endpoint/custom_views_endpoint.py | 80 +- .../data_acceleration_report_endpoint.py | 4 +- .../server/endpoint/data_alert_endpoint.py | 28 +- .../server/endpoint/databases_endpoint.py | 25 +- .../server/endpoint/datasources_endpoint.py | 103 ++- .../endpoint/default_permissions_endpoint.py | 37 +- .../server/endpoint/dqw_endpoint.py | 18 +- .../server/endpoint/endpoint.py | 40 +- .../server/endpoint/exceptions.py | 30 +- .../server/endpoint/favorites_endpoint.py | 62 +- .../server/endpoint/fileuploads_endpoint.py | 20 +- .../server/endpoint/flow_runs_endpoint.py | 28 +- .../server/endpoint/flow_task_endpoint.py | 4 +- .../server/endpoint/flows_endpoint.py | 59 +- .../server/endpoint/groups_endpoint.py | 35 +- .../server/endpoint/groupsets_endpoint.py | 4 +- .../server/endpoint/jobs_endpoint.py | 14 +- .../server/endpoint/linked_tasks_endpoint.py | 4 +- .../server/endpoint/metadata_endpoint.py | 4 +- .../server/endpoint/metrics_endpoint.py | 20 +- .../server/endpoint/permissions_endpoint.py | 28 +- .../server/endpoint/projects_endpoint.py | 111 ++- .../server/endpoint/resource_tagger.py | 27 +- .../server/endpoint/schedules_endpoint.py | 35 +- .../server/endpoint/server_info_endpoint.py | 45 +- .../server/endpoint/sites_endpoint.py | 299 +++++++- .../server/endpoint/subscriptions_endpoint.py | 20 +- .../server/endpoint/tables_endpoint.py | 29 +- .../server/endpoint/tasks_endpoint.py | 16 +- .../server/endpoint/users_endpoint.py | 385 +++++++++- .../server/endpoint/views_endpoint.py | 37 +- .../endpoint/virtual_connections_endpoint.py | 11 +- .../server/endpoint/webhooks_endpoint.py | 22 +- .../server/endpoint/workbooks_endpoint.py | 708 ++++++++++++++++-- tableauserverclient/server/filter.py | 4 +- tableauserverclient/server/pager.py | 11 +- tableauserverclient/server/query.py | 87 ++- tableauserverclient/server/request_factory.py | 73 +- tableauserverclient/server/request_options.py | 268 +++---- tableauserverclient/server/server.py | 74 +- tableauserverclient/server/sort.py | 4 +- test/_utils.py | 14 + test/assets/flow_runs_get.xml | 3 +- test/assets/server_info_wrong_site.html | 56 ++ test/test_auth.py | 6 +- test/test_custom_view.py | 72 ++ test/test_dataalert.py | 2 +- test/test_datasource.py | 10 +- test/test_endpoint.py | 2 +- test/test_favorites.py | 18 +- test/test_filesys_helpers.py | 2 +- test/test_fileuploads.py | 6 +- test/test_flowruns.py | 23 +- test/test_flowtask.py | 2 +- test/test_group.py | 1 - test/test_job.py | 8 +- test/test_pager.py | 12 + test/test_project.py | 36 +- test/test_regression_tests.py | 6 +- test/test_request_option.py | 24 +- test/test_schedule.py | 18 +- test/test_server_info.py | 10 + test/test_site_model.py | 2 - test/test_tagging.py | 4 +- test/test_task.py | 8 +- test/test_user.py | 7 +- test/test_user_model.py | 9 +- test/test_view.py | 6 +- test/test_view_acceleration.py | 2 +- test/test_workbook.py | 12 +- versioneer.py | 47 +- 149 files changed, 3347 insertions(+), 1407 deletions(-) delete mode 100644 samples/update_workbook_data_acceleration.py create mode 100644 test/assets/server_info_wrong_site.html diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml index 41a944e63..0e2b425ee 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 d70539582..2e197cf20 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.8', '3.9', '3.10', '3.11', '3.12'] + 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 3bf47ea23..08f90c49c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,42 +14,42 @@ 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 - 'urllib3==2.2.2', # dependabot + 'requests>=2.32', # latest as at 7/31/23 + 'urllib3>=2.2.2,<3', 'typing_extensions>=4.0.1', ] -requires-python = ">=3.7" +requires-python = ">=3.9" classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12" + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13" ] [project.urls] repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["black==23.7", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", +test = ["black==24.8", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", "requests-mock>=1.0,<2.0"] [tool.black] line-length = 120 -target-version = ['py37', 'py38', 'py39', 'py310', 'py311', 'py312'] +target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] [tool.mypy] 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"] +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/add_default_permission.py b/samples/add_default_permission.py index 5a450e8ab..d26d009e2 100644 --- a/samples/add_default_permission.py +++ b/samples/add_default_permission.py @@ -63,10 +63,10 @@ def main(): for permission in new_default_permissions: grantee = permission.grantee capabilities = permission.capabilities - print("\nCapabilities for {0} {1}:".format(grantee.tag_name, grantee.id)) + print(f"\nCapabilities for {grantee.tag_name} {grantee.id}:") for capability in capabilities: - print("\t{0} - {1}".format(capability, capabilities[capability])) + print(f"\t{capability} - {capabilities[capability]}") # Uncomment lines below to DELETE the new capability and the new project # rules_to_delete = TSC.PermissionsRule( diff --git a/samples/create_group.py b/samples/create_group.py index f4c6a9ca9..aca3e895b 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -11,7 +11,6 @@ import os from datetime import time -from typing import List import tableauserverclient as TSC from tableauserverclient import ServerResponseError @@ -63,23 +62,23 @@ def main(): if args.file: filepath = os.path.abspath(args.file) - print("Add users to site from file {}:".format(filepath)) - added: List[TSC.UserItem] - failed: List[TSC.UserItem, TSC.ServerResponseError] + print(f"Add users to site from file {filepath}:") + added: list[TSC.UserItem] + failed: list[TSC.UserItem, TSC.ServerResponseError] added, failed = server.users.create_from_file(filepath) for user, error in failed: print(user, error.code) if error.code == "409017": user = server.users.filter(name=user.name)[0] added.append(user) - print("Adding users to group:{}".format(added)) + print(f"Adding users to group:{added}") for user in added: - print("Adding user {}".format(user)) + print(f"Adding user {user}") try: server.groups.add_user(group, user.id) except ServerResponseError as serverError: if serverError.code == "409011": - print("user {} is already a member of group {}".format(user.name, group.name)) + print(f"user {user.name} is already a member of group {group.name}") else: raise rError diff --git a/samples/create_project.py b/samples/create_project.py index 1fc649f8c..d775902aa 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -84,7 +84,7 @@ def main(): server.projects.populate_datasource_default_permissions(changed_project), server.projects.populate_permissions(changed_project) # Projects have default permissions set for the object types they contain - print("Permissions from project {}:".format(changed_project.id)) + print(f"Permissions from project {changed_project.id}:") print(changed_project.permissions) print( changed_project.default_workbook_permissions, diff --git a/samples/create_schedules.py b/samples/create_schedules.py index dee088571..c23a2eced 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -55,7 +55,7 @@ def main(): ) try: hourly_schedule = server.schedules.create(hourly_schedule) - print("Hourly schedule created (ID: {}).".format(hourly_schedule.id)) + print(f"Hourly schedule created (ID: {hourly_schedule.id}).") except Exception as e: print(e) @@ -71,7 +71,7 @@ def main(): ) try: daily_schedule = server.schedules.create(daily_schedule) - print("Daily schedule created (ID: {}).".format(daily_schedule.id)) + print(f"Daily schedule created (ID: {daily_schedule.id}).") except Exception as e: print(e) @@ -89,7 +89,7 @@ def main(): ) try: weekly_schedule = server.schedules.create(weekly_schedule) - print("Weekly schedule created (ID: {}).".format(weekly_schedule.id)) + print(f"Weekly schedule created (ID: {weekly_schedule.id}).") except Exception as e: print(e) options = TSC.RequestOptions() @@ -112,7 +112,7 @@ def main(): ) try: monthly_schedule = server.schedules.create(monthly_schedule) - print("Monthly schedule created (ID: {}).".format(monthly_schedule.id)) + print(f"Monthly schedule created (ID: {monthly_schedule.id}).") except Exception as e: print(e) diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index fb45cb45e..c9f35d5be 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -51,16 +51,17 @@ def main(): if args.publish: if default_project is not None: new_datasource = TSC.DatasourceItem(default_project.id) + new_datasource.description = "Published with a description" new_datasource = server.datasources.publish( new_datasource, args.publish, TSC.Server.PublishMode.Overwrite ) - print("Datasource published. ID: {}".format(new_datasource.id)) + print(f"Datasource published. ID: {new_datasource.id}") else: print("Publish failed. Could not find the default project.") # Gets all datasource items all_datasources, pagination_item = server.datasources.get() - print("\nThere are {} datasources on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} datasources on site: ") print([datasource.name for datasource in all_datasources]) if all_datasources: @@ -69,20 +70,19 @@ def main(): # Populate connections server.datasources.populate_connections(sample_datasource) - print("\nConnections for {}: ".format(sample_datasource.name)) - print( - [ - "{0}({1})".format(connection.id, connection.datasource_name) - for connection in sample_datasource.connections - ] - ) + print(f"\nConnections for {sample_datasource.name}: ") + print([f"{connection.id}({connection.datasource_name})" for connection in sample_datasource.connections]) + + # Demonstrate that description is editable + sample_datasource.description = "Description updated by TSC" + server.datasources.update(sample_datasource) # Add some tags to the datasource original_tag_set = set(sample_datasource.tags) sample_datasource.tags.update("a", "b", "c", "d") server.datasources.update(sample_datasource) - print("\nOld tag set: {}".format(original_tag_set)) - print("New tag set: {}".format(sample_datasource.tags)) + print(f"\nOld tag set: {original_tag_set}") + print(f"New tag set: {sample_datasource.tags}") # Delete all tags that were added by setting tags to original sample_datasource.tags = original_tag_set diff --git a/samples/explore_favorites.py b/samples/explore_favorites.py index 243e91954..f199522ed 100644 --- a/samples/explore_favorites.py +++ b/samples/explore_favorites.py @@ -3,7 +3,7 @@ import argparse import logging import tableauserverclient as TSC -from tableauserverclient import Resource +from tableauserverclient.models import Resource def main(): @@ -39,15 +39,15 @@ def main(): # get all favorites on site for the logged on user user: TSC.UserItem = TSC.UserItem() user.id = server.user_id - print("Favorites for user: {}".format(user.id)) + print(f"Favorites for user: {user.id}") server.favorites.get(user) print(user.favorites) # get list of workbooks all_workbook_items, pagination_item = server.workbooks.get() if all_workbook_items is not None and len(all_workbook_items) > 0: - my_workbook: TSC.WorkbookItem = all_workbook_items[0] - server.favorites.add_favorite(server, user, Resource.Workbook.name(), all_workbook_items[0]) + my_workbook = all_workbook_items[0] + server.favorites.add_favorite(user, Resource.Workbook, all_workbook_items[0]) print( "Workbook added to favorites. Workbook Name: {}, Workbook ID: {}".format( my_workbook.name, my_workbook.id @@ -57,7 +57,7 @@ def main(): if views is not None and len(views) > 0: my_view = views[0] server.favorites.add_favorite_view(user, my_view) - print("View added to favorites. View Name: {}, View ID: {}".format(my_view.name, my_view.id)) + print(f"View added to favorites. View Name: {my_view.name}, View ID: {my_view.id}") all_datasource_items, pagination_item = server.datasources.get() if all_datasource_items: @@ -70,12 +70,10 @@ def main(): ) server.favorites.delete_favorite_workbook(user, my_workbook) - print( - "Workbook deleted from favorites. Workbook Name: {}, Workbook ID: {}".format(my_workbook.name, my_workbook.id) - ) + print(f"Workbook deleted from favorites. Workbook Name: {my_workbook.name}, Workbook ID: {my_workbook.id}") server.favorites.delete_favorite_view(user, my_view) - print("View deleted from favorites. View Name: {}, View ID: {}".format(my_view.name, my_view.id)) + print(f"View deleted from favorites. View Name: {my_view.name}, View ID: {my_view.id}") server.favorites.delete_favorite_datasource(user, my_datasource) print( diff --git a/samples/explore_site.py b/samples/explore_site.py index a2274f1a7..eb9eba0de 100644 --- a/samples/explore_site.py +++ b/samples/explore_site.py @@ -49,7 +49,7 @@ def main(): if args.delete: print("You can only delete the site you are currently in") - print("Delete site `{}`?".format(current_site.name)) + print(f"Delete site `{current_site.name}`?") # server.sites.delete(server.site_id) elif args.create: diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index 77802b1db..f25c41849 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -52,11 +52,11 @@ def main(): new_webhook.event = "datasource-created" print(new_webhook) new_webhook = server.webhooks.create(new_webhook) - print("Webhook created. ID: {}".format(new_webhook.id)) + print(f"Webhook created. ID: {new_webhook.id}") # Gets all webhook items all_webhooks, pagination_item = server.webhooks.get() - print("\nThere are {} webhooks on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} webhooks on site: ") print([webhook.name for webhook in all_webhooks]) if all_webhooks: diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index 57f88aa07..f51639ab3 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -59,13 +59,13 @@ def main(): if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) new_workbook = server.workbooks.publish(new_workbook, args.publish, overwrite_true) - print("Workbook published. ID: {}".format(new_workbook.id)) + print(f"Workbook published. ID: {new_workbook.id}") else: print("Publish failed. Could not find the default project.") # Gets all workbook items all_workbooks, pagination_item = server.workbooks.get() - print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} workbooks on site: ") print([workbook.name for workbook in all_workbooks]) if all_workbooks: @@ -78,27 +78,22 @@ def main(): # Populate views server.workbooks.populate_views(sample_workbook) - print("\nName of views in {}: ".format(sample_workbook.name)) + print(f"\nName of views in {sample_workbook.name}: ") print([view.name for view in sample_workbook.views]) # Populate connections server.workbooks.populate_connections(sample_workbook) - print("\nConnections for {}: ".format(sample_workbook.name)) - print( - [ - "{0}({1})".format(connection.id, connection.datasource_name) - for connection in sample_workbook.connections - ] - ) + print(f"\nConnections for {sample_workbook.name}: ") + print([f"{connection.id}({connection.datasource_name})" for connection in sample_workbook.connections]) # Update tags and show_tabs flag original_tag_set = set(sample_workbook.tags) sample_workbook.tags.update("a", "b", "c", "d") sample_workbook.show_tabs = True server.workbooks.update(sample_workbook) - print("\nWorkbook's old tag set: {}".format(original_tag_set)) - print("Workbook's new tag set: {}".format(sample_workbook.tags)) - print("Workbook tabbed: {}".format(sample_workbook.show_tabs)) + print(f"\nWorkbook's old tag set: {original_tag_set}") + print(f"Workbook's new tag set: {sample_workbook.tags}") + print(f"Workbook tabbed: {sample_workbook.show_tabs}") # Delete all tags that were added by setting tags to original sample_workbook.tags = original_tag_set @@ -109,8 +104,8 @@ def main(): original_tag_set = set(sample_view.tags) sample_view.tags.add("view_tag") server.views.update(sample_view) - print("\nView's old tag set: {}".format(original_tag_set)) - print("View's new tag set: {}".format(sample_view.tags)) + print(f"\nView's old tag set: {original_tag_set}") + print(f"View's new tag set: {sample_view.tags}") # Delete tag from just one view sample_view.tags = original_tag_set @@ -119,14 +114,14 @@ def main(): if args.download: # Download path = server.workbooks.download(sample_workbook.id, args.download) - print("\nDownloaded workbook to {}".format(path)) + print(f"\nDownloaded workbook to {path}") if args.preview_image: # Populate workbook preview image server.workbooks.populate_preview_image(sample_workbook) with open(args.preview_image, "wb") as f: f.write(sample_workbook.preview_image) - print("\nDownloaded preview image of workbook to {}".format(os.path.abspath(args.preview_image))) + print(f"\nDownloaded preview image of workbook to {os.path.abspath(args.preview_image)}") # get custom views cvs, _ = server.custom_views.get() @@ -153,10 +148,10 @@ def main(): server.workbooks.populate_powerpoint(sample_workbook) with open(args.powerpoint, "wb") as f: f.write(sample_workbook.powerpoint) - print("\nDownloaded powerpoint of workbook to {}".format(os.path.abspath(args.powerpoint))) + print(f"\nDownloaded powerpoint of workbook to {os.path.abspath(args.powerpoint)}") if args.delete: - print("deleting {}".format(c.id)) + print(f"deleting {c.id}") unlucky = TSC.CustomViewItem(c.id) server.custom_views.delete(unlucky.id) diff --git a/samples/export.py b/samples/export.py index f2783fa6e..b2506cf46 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,14 +59,16 @@ 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) if not item: - print("No item found for id {}".format(args.resource_id)) + print(f"No item found for id {args.resource_id}") exit(1) - print("Item found: {}".format(item.name)) + print(f"Item found: {item.name}") # We have a number of different types and functions for each different export type. # We encode that information above in the const=(...) parameter to the add_argument function to make # the code automatically adapt for the type of export the user is doing. @@ -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 = "out.{}".format(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 9bd87a473..c0dd885bc 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 @@ -47,7 +41,7 @@ def main(): with server.auth.sign_in(tableau_auth): # Gets all workbook items all_workbooks, pagination_item = server.workbooks.get() - print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} workbooks on site: ") print([workbook.name for workbook in all_workbooks]) if all_workbooks: diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index 042af32e2..1694bf0f5 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 = "No project named '{}' found".format(filter_group_name) + 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/filter_sort_projects.py b/samples/filter_sort_projects.py index 7aa62a5c1..6c3a85dcd 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -68,7 +68,7 @@ def main(): project_name = filtered_projects.pop().name print(project_name) else: - error = "No project named '{}' found".format(filter_project_name) + error = f"No project named '{filter_project_name}' found" print(error) create_example_project(name="Example 1", server=server) diff --git a/samples/getting_started/1_hello_server.py b/samples/getting_started/1_hello_server.py index 454b225de..5f8cfa238 100644 --- a/samples/getting_started/1_hello_server.py +++ b/samples/getting_started/1_hello_server.py @@ -12,8 +12,8 @@ def main(): # This is the domain for Tableau's Developer Program server_url = "https://10ax.online.tableau.com" server = TSC.Server(server_url) - print("Connected to {}".format(server.server_info.baseurl)) - print("Server information: {}".format(server.server_info)) + print(f"Connected to {server.server_info.baseurl}") + print(f"Server information: {server.server_info}") print("Sign up for a test site at https://www.tableau.com/developer") diff --git a/samples/getting_started/2_hello_site.py b/samples/getting_started/2_hello_site.py index d62896059..8635947a8 100644 --- a/samples/getting_started/2_hello_site.py +++ b/samples/getting_started/2_hello_site.py @@ -19,7 +19,7 @@ def main(): use_ssl = True server = TSC.Server(server_url, use_server_version=True, http_options={"verify": use_ssl}) - print("Connected to {}".format(server.server_info.baseurl)) + print(f"Connected to {server.server_info.baseurl}") # 3 - replace with your site name exactly as it looks in the url # e.g https://my-server/#/site/this-is-your-site-url-name/not-this-part @@ -39,7 +39,7 @@ def main(): with server.auth.sign_in(tableau_auth): projects, pagination = server.projects.get() if projects: - print("{} projects".format(pagination.total_available)) + print(f"{pagination.total_available} projects") project = projects[0] print(project.name) diff --git a/samples/getting_started/3_hello_universe.py b/samples/getting_started/3_hello_universe.py index 21de97831..a2c4301d0 100644 --- a/samples/getting_started/3_hello_universe.py +++ b/samples/getting_started/3_hello_universe.py @@ -17,7 +17,7 @@ def main(): use_ssl = True server = TSC.Server(server_url, use_server_version=True, http_options={"verify": use_ssl}) - print("Connected to {}".format(server.server_info.baseurl)) + print(f"Connected to {server.server_info.baseurl}") # 3 - replace with your site name exactly as it looks in a url # e.g https://my-server/#/this-is-your-site-url-name/ @@ -36,55 +36,55 @@ def main(): with server.auth.sign_in(tableau_auth): projects, pagination = server.projects.get() if projects: - print("{} projects".format(pagination.total_available)) + print(f"{pagination.total_available} projects") for project in projects: print(project.name) workbooks, pagination = server.datasources.get() if workbooks: - print("{} workbooks".format(pagination.total_available)) + print(f"{pagination.total_available} workbooks") print(workbooks[0]) views, pagination = server.views.get() if views: - print("{} views".format(pagination.total_available)) + print(f"{pagination.total_available} views") print(views[0]) datasources, pagination = server.datasources.get() if datasources: - print("{} datasources".format(pagination.total_available)) + print(f"{pagination.total_available} datasources") print(datasources[0]) # I think all these other content types can go to a hello_universe script # data alert, dqw, flow, ... do any of these require any add-ons? jobs, pagination = server.jobs.get() if jobs: - print("{} jobs".format(pagination.total_available)) + print(f"{pagination.total_available} jobs") print(jobs[0]) schedules, pagination = server.schedules.get() if schedules: - print("{} schedules".format(pagination.total_available)) + print(f"{pagination.total_available} schedules") print(schedules[0]) tasks, pagination = server.tasks.get() if tasks: - print("{} tasks".format(pagination.total_available)) + print(f"{pagination.total_available} tasks") print(tasks[0]) webhooks, pagination = server.webhooks.get() if webhooks: - print("{} webhooks".format(pagination.total_available)) + print(f"{pagination.total_available} webhooks") print(webhooks[0]) users, pagination = server.users.get() if users: - print("{} users".format(pagination.total_available)) + print(f"{pagination.total_available} users") print(users[0]) groups, pagination = server.groups.get() if groups: - print("{} groups".format(pagination.total_available)) + print(f"{pagination.total_available} groups") print(groups[0]) diff --git a/samples/initialize_server.py b/samples/initialize_server.py index cb3d9e1d0..cdfaf27a8 100644 --- a/samples/initialize_server.py +++ b/samples/initialize_server.py @@ -51,7 +51,7 @@ def main(): # Create the site if it doesn't exist if existing_site is None: - print("Site not found: {0} Creating it...".format(args.site_id)) + print(f"Site not found: {args.site_id} Creating it...") new_site = TSC.SiteItem( name=args.site_id, content_url=args.site_id.replace(" ", ""), @@ -59,7 +59,7 @@ def main(): ) server.sites.create(new_site) else: - print("Site {0} exists. Moving on...".format(args.site_id)) + print(f"Site {args.site_id} exists. Moving on...") ################################################################################ # Step 3: Sign-in to our target site @@ -81,7 +81,7 @@ def main(): # Create our project if it doesn't exist if project is None: - print("Project not found: {0} Creating it...".format(args.project)) + print(f"Project not found: {args.project} Creating it...") new_project = TSC.ProjectItem(name=args.project) project = server_upload.projects.create(new_project) @@ -100,7 +100,7 @@ def publish_datasources_to_site(server_object, project, folder): for fname in glob.glob(path): new_ds = TSC.DatasourceItem(project.id) new_ds = server_object.datasources.publish(new_ds, fname, server_object.PublishMode.Overwrite) - print("Datasource published. ID: {0}".format(new_ds.id)) + print(f"Datasource published. ID: {new_ds.id}") def publish_workbooks_to_site(server_object, project, folder): @@ -110,7 +110,7 @@ def publish_workbooks_to_site(server_object, project, folder): new_workbook = TSC.WorkbookItem(project.id) new_workbook.show_tabs = True new_workbook = server_object.workbooks.publish(new_workbook, fname, server_object.PublishMode.Overwrite) - print("Workbook published. ID: {0}".format(new_workbook.id)) + print(f"Workbook published. ID: {new_workbook.id}") if __name__ == "__main__": diff --git a/samples/list.py b/samples/list.py index 8d72fb620..2675a2954 100644 --- a/samples/list.py +++ b/samples/list.py @@ -48,6 +48,9 @@ def main(): "webhooks": server.webhooks, "workbook": server.workbooks, }.get(args.resource_type) + if endpoint is None: + print("Resource type not found.") + sys.exit(1) options = TSC.RequestOptions() options.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Desc)) @@ -59,7 +62,7 @@ def main(): print(resource.name[:18], " ") # , resource._connections()) if count > 100: break - print("Total: {}".format(count)) + print(f"Total: {count}") if __name__ == "__main__": diff --git a/samples/login.py b/samples/login.py index 6a3e9e8b3..bc99385b3 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) @@ -59,7 +65,7 @@ def sample_connect_to_server(args): password = args.password or getpass.getpass("Password: ") tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.site) - print("\nSigning in...\nServer: {}\nSite: {}\nUsername: {}".format(args.server, args.site, args.username)) + print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nUsername: {args.username}") else: # Trying to authenticate using personal access tokens. @@ -68,7 +74,7 @@ def sample_connect_to_server(args): tableau_auth = TSC.PersonalAccessTokenAuth( token_name=args.token_name, personal_access_token=token, site_id=args.site ) - print("\nSigning in...\nServer: {}\nSite: {}\nToken name: {}".format(args.server, args.site, args.token_name)) + print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nToken name: {args.token_name}") if not tableau_auth: raise TabError("Did not create authentication object. Check arguments.") @@ -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/move_workbook_sites.py b/samples/move_workbook_sites.py index 47af1f2f9..e82c75cf9 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -59,7 +59,7 @@ def main(): # Step 3: Download workbook to a temp directory if len(all_workbooks) == 0: - print("No workbook named {} found.".format(args.workbook_name)) + print(f"No workbook named {args.workbook_name} found.") else: tmpdir = tempfile.mkdtemp() try: @@ -68,10 +68,10 @@ def main(): # Step 4: Check if destination site exists, then sign in to the site all_sites, pagination_info = source_server.sites.get() found_destination_site = any( - (True for site in all_sites if args.destination_site.lower() == site.content_url.lower()) + True for site in all_sites if args.destination_site.lower() == site.content_url.lower() ) if not found_destination_site: - error = "No site named {} found.".format(args.destination_site) + error = f"No site named {args.destination_site} found." raise LookupError(error) tableau_auth.site_id = args.destination_site @@ -85,7 +85,7 @@ def main(): new_workbook = dest_server.workbooks.publish( new_workbook, workbook_path, mode=TSC.Server.PublishMode.Overwrite ) - print("Successfully moved {0} ({1})".format(new_workbook.name, new_workbook.id)) + print(f"Successfully moved {new_workbook.name} ({new_workbook.id})") # Step 6: Delete workbook from source site and delete temp directory source_server.workbooks.delete(all_workbooks[0].id) diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index a7ae6dc89..a68eed4b3 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -57,7 +57,7 @@ def main(): for wb in TSC.Pager(server.workbooks, page_options): print(wb.name) count = count + 1 - print("Total: {}\n".format(count)) + print(f"Total: {count}\n") count = 0 page_options = TSC.RequestOptions(2, 3) @@ -65,7 +65,7 @@ def main(): for wb in TSC.Pager(server.workbooks, page_options): print(wb.name) count = count + 1 - print("Truncated Total: {}\n".format(count)) + print(f"Truncated Total: {count}\n") print("Your id: ", you.name, you.id, "\n") count = 0 @@ -76,7 +76,7 @@ def main(): for wb in TSC.Pager(server.workbooks, filtered_page_options): print(wb.name, " -- ", wb.owner_id) count = count + 1 - print("Filtered Total: {}\n".format(count)) + print(f"Filtered Total: {count}\n") # 2. QuerySet offers a fluent interface on top of the RequestOptions object print("Fetching workbooks again - this time filtered with QuerySet") @@ -90,7 +90,7 @@ def main(): count = count + 1 more = queryset.total_available > count page = page + 1 - print("QuerySet Total: {}".format(count)) + print(f"QuerySet Total: {count}") # 3. QuerySet also allows you to iterate over all objects without explicitly paging. print("Fetching again - this time without manually paging") diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index 5ac768674..c674e6882 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 @@ -111,15 +116,17 @@ def main(): new_job = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds, as_job=True ) - print("Datasource published asynchronously. Job ID: {0}".format(new_job.id)) + print(f"Datasource published asynchronously. Job ID: {new_job.id}") else: # Normal publishing, returns a datasource_item new_datasource = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds ) print( - "{0}Datasource published. Datasource ID: {1}".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/publish_workbook.py b/samples/publish_workbook.py index 8a9f45279..d31978c0f 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -80,7 +80,7 @@ def main(): as_job=args.as_job, skip_connection_check=args.skip_connection_check, ) - print("Workbook published. JOB ID: {0}".format(new_job.id)) + print(f"Workbook published. JOB ID: {new_job.id}") else: new_workbook = server.workbooks.publish( new_workbook, @@ -90,7 +90,7 @@ def main(): as_job=args.as_job, skip_connection_check=args.skip_connection_check, ) - print("Workbook published. ID: {0}".format(new_workbook.id)) + print(f"Workbook published. ID: {new_workbook.id}") else: error = "The default project could not be found." raise LookupError(error) diff --git a/samples/query_permissions.py b/samples/query_permissions.py index 4e509cd97..3309acd90 100644 --- a/samples/query_permissions.py +++ b/samples/query_permissions.py @@ -57,17 +57,15 @@ def main(): permissions = resource.permissions # Print result - print( - "\n{0} permission rule(s) found for {1} {2}.".format(len(permissions), args.resource_type, args.resource_id) - ) + print(f"\n{len(permissions)} permission rule(s) found for {args.resource_type} {args.resource_id}.") for permission in permissions: grantee = permission.grantee capabilities = permission.capabilities - print("\nCapabilities for {0} {1}:".format(grantee.tag_name, grantee.id)) + print(f"\nCapabilities for {grantee.tag_name} {grantee.id}:") for capability in capabilities: - print("\t{0} - {1}".format(capability, capabilities[capability])) + print(f"\t{capability} - {capabilities[capability]}") if __name__ == "__main__": diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index 03daedf16..c95000898 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -19,12 +19,12 @@ def handle_run(server, args): def handle_list(server, _): tasks, pagination = server.tasks.get() for task in tasks: - print("{}".format(task)) + print(f"{task}") def handle_info(server, args): task = server.tasks.get_by_id(args.id) - print("{}".format(task)) + print(f"{task}") def main(): diff --git a/samples/set_refresh_schedule.py b/samples/set_refresh_schedule.py index 56fd12e62..153bb0ee5 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/update_connection.py b/samples/update_connection.py index 4af6592bc..0fe2f342c 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 75f12262f..000000000 --- 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("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) - 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/samples/update_workbook_data_freshness_policy.py b/samples/update_workbook_data_freshness_policy.py index 9e4d63dc1..c23e3717f 100644 --- a/samples/update_workbook_data_freshness_policy.py +++ b/samples/update_workbook_data_freshness_policy.py @@ -45,7 +45,7 @@ def main(): with server.auth.sign_in(tableau_auth): # Get workbook all_workbooks, pagination_item = server.workbooks.get() - print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} workbooks on site: ") print([workbook.name for workbook in all_workbooks]) if all_workbooks: diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index bab2cf05f..e0a7abb64 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/_version.py b/tableauserverclient/_version.py index d47374097..79dbed1d8 100644 --- a/tableauserverclient/_version.py +++ b/tableauserverclient/_version.py @@ -84,7 +84,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= stderr=(subprocess.PIPE if hide_stderr else None), ) break - except EnvironmentError: + except OSError: e = sys.exc_info()[1] if e.errno == errno.ENOENT: continue @@ -94,7 +94,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= return None, None else: if verbose: - print("unable to find command, tried %s" % (commands,)) + print(f"unable to find command, tried {commands}") return None, None stdout = p.communicate()[0].strip() if sys.version_info[0] >= 3: @@ -131,7 +131,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): root = os.path.dirname(root) # up a level if verbose: - print("Tried directories %s but none started with prefix %s" % (str(rootdirs), parentdir_prefix)) + print(f"Tried directories {str(rootdirs)} but none started with prefix {parentdir_prefix}") raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @@ -144,7 +144,7 @@ def git_get_keywords(versionfile_abs): # _version.py. keywords = {} try: - f = open(versionfile_abs, "r") + f = open(versionfile_abs) for line in f.readlines(): if line.strip().startswith("git_refnames ="): mo = re.search(r'=\s*"(.*)"', line) @@ -159,7 +159,7 @@ def git_get_keywords(versionfile_abs): if mo: keywords["date"] = mo.group(1) f.close() - except EnvironmentError: + except OSError: pass return keywords @@ -183,11 +183,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): if verbose: print("keywords are unexpanded, not using") raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = set([r.strip() for r in refnames.strip("()").split(",")]) + refs = {r.strip() for r in refnames.strip("()").split(",")} # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) + tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -196,7 +196,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r"\d", r)]) + tags = {r for r in refs if re.search(r"\d", r)} if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -299,7 +299,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % ( + pieces["error"] = "tag '{}' doesn't start with prefix '{}'".format( full_tag, tag_prefix, ) diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py index 63872398f..a75112754 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/column_item.py b/tableauserverclient/models/column_item.py index df936e315..3a7416e28 100644 --- a/tableauserverclient/models/column_item.py +++ b/tableauserverclient/models/column_item.py @@ -3,7 +3,7 @@ from .property_decorators import property_not_empty -class ColumnItem(object): +class ColumnItem: def __init__(self, name, description=None): self._id = None self.description = description diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py index d61bbb751..bb2cbbba9 100644 --- a/tableauserverclient/models/connection_credentials.py +++ b/tableauserverclient/models/connection_credentials.py @@ -1,7 +1,7 @@ from .property_decorators import property_is_boolean -class ConnectionCredentials(object): +class ConnectionCredentials: """Connection Credentials for Workbooks and Datasources publish request. Consider removing this object and other variables holding secrets diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 62ff530c9..937e43481 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -8,7 +8,7 @@ from tableauserverclient.helpers.logging import logger -class ConnectionItem(object): +class ConnectionItem: def __init__(self): self._datasource_id: Optional[str] = None self._datasource_name: Optional[str] = None @@ -48,7 +48,7 @@ def query_tagging(self, value: Optional[bool]): # if connection type = hyper, Snowflake, or Teradata, we can't change this value: it is always true if self._connection_type in ["hyper", "snowflake", "teradata"]: logger.debug( - "Cannot update value: Query tagging is always enabled for {} connections".format(self._connection_type) + f"Cannot update value: Query tagging is always enabled for {self._connection_type} connections" ) return self._query_tagging = value @@ -59,7 +59,7 @@ def __repr__(self): ) @classmethod - def from_response(cls, resp, ns) -> List["ConnectionItem"]: + def from_response(cls, resp, ns) -> list["ConnectionItem"]: all_connection_items = list() parsed_response = fromstring(resp) all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) @@ -82,7 +82,7 @@ def from_response(cls, resp, ns) -> List["ConnectionItem"]: return all_connection_items @classmethod - def from_xml_element(cls, parsed_response, ns) -> List["ConnectionItem"]: + def from_xml_element(cls, parsed_response, ns) -> list["ConnectionItem"]: """ @@ -93,7 +93,7 @@ def from_xml_element(cls, parsed_response, ns) -> List["ConnectionItem"]: """ - all_connection_items: List["ConnectionItem"] = list() + all_connection_items: list["ConnectionItem"] = list() all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) for connection_xml in all_connection_xml: diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py index 246a19e7f..a0c0a9844 100644 --- a/tableauserverclient/models/custom_view_item.py +++ b/tableauserverclient/models/custom_view_item.py @@ -2,7 +2,8 @@ from defusedxml import ElementTree from defusedxml.ElementTree import fromstring, tostring -from typing import Callable, List, Optional +from typing import Callable, Optional +from collections.abc import Iterator from .exceptions import UnpopulatedPropertyError from .user_item import UserItem @@ -11,12 +12,14 @@ from ..datetime_helpers import parse_datetime -class CustomViewItem(object): +class CustomViewItem: def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None: self._content_url: Optional[str] = None # ? self._created_at: Optional["datetime"] = None self._id: Optional[str] = id self._image: Optional[Callable[[], bytes]] = None + self._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 @@ -35,11 +38,17 @@ def __repr__(self: "CustomViewItem"): owner_info = "" if self._owner: owner_info = " owner='{}'".format(self._owner.name or self._owner.id or "unknown") - return "".format(self.id, self.name, view_info, wb_info, owner_info) + return f"" 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 @@ -104,7 +127,7 @@ def from_response(cls, resp, ns, workbook_id="") -> Optional["CustomViewItem"]: return item[0] @classmethod - def list_from_response(cls, resp, ns, workbook_id="") -> List["CustomViewItem"]: + def list_from_response(cls, resp, ns, workbook_id="") -> list["CustomViewItem"]: return cls.from_xml_element(fromstring(resp), ns, workbook_id) """ @@ -121,7 +144,7 @@ def list_from_response(cls, resp, ns, workbook_id="") -> List["CustomViewItem"]: """ @classmethod - def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["CustomViewItem"]: + def from_xml_element(cls, parsed_response, ns, workbook_id="") -> list["CustomViewItem"]: all_view_items = list() all_view_xml = parsed_response.findall(".//t:customView", namespaces=ns) for custom_view_xml in all_view_xml: diff --git a/tableauserverclient/models/data_acceleration_report_item.py b/tableauserverclient/models/data_acceleration_report_item.py index 7424e6b95..3a8883bed 100644 --- a/tableauserverclient/models/data_acceleration_report_item.py +++ b/tableauserverclient/models/data_acceleration_report_item.py @@ -1,8 +1,8 @@ from defusedxml.ElementTree import fromstring -class DataAccelerationReportItem(object): - class ComparisonRecord(object): +class DataAccelerationReportItem: + class ComparisonRecord: def __init__( self, site, diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index 65be233e3..7285ee609 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -10,7 +10,7 @@ ) -class DataAlertItem(object): +class DataAlertItem: class Frequency: Once = "Once" Frequently = "Frequently" @@ -34,7 +34,7 @@ def __init__(self): self._workbook_name: Optional[str] = None self._project_id: Optional[str] = None self._project_name: Optional[str] = None - self._recipients: Optional[List[str]] = None + self._recipients: Optional[list[str]] = None def __repr__(self) -> str: return " Optional[str]: return self._creatorId @property - def recipients(self) -> List[str]: + def recipients(self) -> list[str]: return self._recipients or list() @property @@ -174,7 +174,7 @@ def _set_values( self._recipients = recipients @classmethod - def from_response(cls, resp, ns) -> List["DataAlertItem"]: + def from_response(cls, resp, ns) -> list["DataAlertItem"]: all_alert_items = list() parsed_response = fromstring(resp) all_alert_xml = parsed_response.findall(".//t:dataAlert", namespaces=ns) diff --git a/tableauserverclient/models/data_freshness_policy_item.py b/tableauserverclient/models/data_freshness_policy_item.py index f567c501c..6e0cb9001 100644 --- a/tableauserverclient/models/data_freshness_policy_item.py +++ b/tableauserverclient/models/data_freshness_policy_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET -from typing import Optional, Union, List +from typing import Optional from tableauserverclient.models.property_decorators import property_is_enum, property_not_nullable from .interval_item import IntervalItem @@ -50,11 +50,11 @@ class Frequency: Week = "Week" Month = "Month" - def __init__(self, frequency: str, time: str, timezone, interval_item: Optional[List[str]] = None): + def __init__(self, frequency: str, time: str, timezone, interval_item: Optional[list[str]] = None): self.frequency = frequency self.time = time self.timezone = timezone - self.interval_item: Optional[List[str]] = interval_item + self.interval_item: Optional[list[str]] = interval_item def __repr__(self): return ( @@ -62,11 +62,11 @@ def __repr__(self): ).format(**vars(self)) @property - def interval_item(self) -> Optional[List[str]]: + def interval_item(self) -> Optional[list[str]]: return self._interval_item @interval_item.setter - def interval_item(self, value: List[str]): + def interval_item(self, value: list[str]): self._interval_item = value @property @@ -186,7 +186,7 @@ def parse_week_intervals(interval_values): def parse_month_intervals(interval_values): - error = "Invalid interval value for a monthly frequency: {}.".format(interval_values) + error = f"Invalid interval value for a monthly frequency: {interval_values}." # Month interval can have value either only ['LastDay'] or list of dates e.g. ["1", 20", "30"] # First check if the list only have LastDay value. When using LastDay, there shouldn't be diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py index dfc58e1bb..4d4604461 100644 --- a/tableauserverclient/models/database_item.py +++ b/tableauserverclient/models/database_item.py @@ -10,7 +10,7 @@ ) -class DatabaseItem(object): +class DatabaseItem: class ContentPermissions: LockedToProject = "LockedToDatabase" ManagedByOwner = "ManagedByOwner" @@ -45,7 +45,7 @@ def __init__(self, name, description=None, content_permissions=None): self._tables = None # Not implemented yet def __str__(self): - return "".format(self._id, self.name) + return f"" def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @@ -250,7 +250,7 @@ def _set_tables(self, tables): self._tables = tables def _set_default_permissions(self, permissions, content_type): - attr = "_default_{content}_permissions".format(content=content_type) + attr = f"_default_{content_type}_permissions" setattr( self, attr, diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index e4e71c4a2..1b082c157 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -1,7 +1,7 @@ import copy import datetime import xml.etree.ElementTree as ET -from typing import Dict, List, Optional, Set, Tuple +from typing import Optional from defusedxml.ElementTree import fromstring @@ -18,14 +18,14 @@ from tableauserverclient.models.tag_item import TagItem -class DatasourceItem(object): +class DatasourceItem: class AskDataEnablement: Enabled = "Enabled" Disabled = "Disabled" SiteDefault = "SiteDefault" def __repr__(self): - return "".format( + return "".format( self._id, self.name, self.description or "No Description", @@ -44,7 +44,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self._encrypt_extracts = None self._has_extracts = None self._id: Optional[str] = None - self._initial_tags: Set = set() + self._initial_tags: set = set() self._project_name: Optional[str] = None self._revisions = None self._size: Optional[int] = None @@ -55,7 +55,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self.name = name self.owner_id: Optional[str] = None self.project_id = project_id - self.tags: Set[str] = set() + self.tags: set[str] = set() self._permissions = None self._data_quality_warnings = None @@ -72,14 +72,14 @@ def ask_data_enablement(self, value: Optional[AskDataEnablement]): self._ask_data_enablement = value @property - def connections(self) -> Optional[List[ConnectionItem]]: + def connections(self) -> Optional[list[ConnectionItem]]: if self._connections is None: error = "Datasource item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self) -> Optional[List[PermissionsRule]]: + def permissions(self) -> Optional[list[PermissionsRule]]: if self._permissions is None: error = "Project item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -177,7 +177,7 @@ def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def revisions(self) -> List[RevisionItem]: + def revisions(self) -> list[RevisionItem]: if self._revisions is None: error = "Datasource item must be populated with revisions first." raise UnpopulatedPropertyError(error) @@ -309,7 +309,7 @@ def _set_values( self._size = int(size) @classmethod - def from_response(cls, resp: str, ns: Dict) -> List["DatasourceItem"]: + def from_response(cls, resp: str, ns: dict) -> list["DatasourceItem"]: all_datasource_items = list() parsed_response = fromstring(resp) all_datasource_xml = parsed_response.findall(".//t:datasource", namespaces=ns) @@ -326,7 +326,7 @@ def from_xml(cls, datasource_xml, ns): return datasource_item @staticmethod - def _parse_element(datasource_xml: ET.Element, ns: Dict) -> Tuple: + def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: id_ = datasource_xml.get("id", None) name = datasource_xml.get("name", None) datasource_type = datasource_xml.get("type", None) diff --git a/tableauserverclient/models/dqw_item.py b/tableauserverclient/models/dqw_item.py index ada041481..fbda9d9f2 100644 --- a/tableauserverclient/models/dqw_item.py +++ b/tableauserverclient/models/dqw_item.py @@ -3,7 +3,7 @@ from tableauserverclient.datetime_helpers import parse_datetime -class DQWItem(object): +class DQWItem: class WarningType: WARNING = "WARNING" DEPRECATED = "DEPRECATED" diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index caff755e3..4fea280f7 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -1,28 +1,27 @@ import logging +from typing import Union from defusedxml.ElementTree import fromstring -from tableauserverclient.models.tableau_types import TableauItem +from tableauserverclient.models.tableau_types import TableauItem from tableauserverclient.models.datasource_item import DatasourceItem from tableauserverclient.models.flow_item import FlowItem from tableauserverclient.models.project_item import ProjectItem from tableauserverclient.models.metric_item import MetricItem from tableauserverclient.models.view_item import ViewItem from tableauserverclient.models.workbook_item import WorkbookItem -from typing import Dict, List from tableauserverclient.helpers.logging import logger -from typing import Dict, List, Union -FavoriteType = Dict[ +FavoriteType = dict[ str, - List[TableauItem], + list[TableauItem], ] class FavoriteItem: @classmethod - def from_response(cls, xml: str, namespace: Dict) -> FavoriteType: + def from_response(cls, xml: Union[str, bytes], namespace: dict) -> FavoriteType: favorites: FavoriteType = { "datasources": [], "flows": [], diff --git a/tableauserverclient/models/fileupload_item.py b/tableauserverclient/models/fileupload_item.py index e9bdd25b2..aea4dfe1f 100644 --- a/tableauserverclient/models/fileupload_item.py +++ b/tableauserverclient/models/fileupload_item.py @@ -1,7 +1,7 @@ from defusedxml.ElementTree import fromstring -class FileuploadItem(object): +class FileuploadItem: def __init__(self): self._file_size = None self._upload_session_id = None diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index edce2ec97..9bcad5e89 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -1,7 +1,7 @@ import copy import datetime import xml.etree.ElementTree as ET -from typing import List, Optional, Set +from typing import Optional from defusedxml.ElementTree import fromstring @@ -14,9 +14,9 @@ from tableauserverclient.models.tag_item import TagItem -class FlowItem(object): +class FlowItem: def __repr__(self): - return " None: self._webpage_url: Optional[str] = None self._created_at: Optional[datetime.datetime] = None self._id: Optional[str] = None - self._initial_tags: Set[str] = set() + self._initial_tags: set[str] = set() self._project_name: Optional[str] = None self._updated_at: Optional[datetime.datetime] = None self.name: Optional[str] = name self.owner_id: Optional[str] = None self.project_id: str = project_id - self.tags: Set[str] = set() + self.tags: set[str] = set() self.description: Optional[str] = None self._connections: Optional[ConnectionItem] = None @@ -170,7 +170,7 @@ def _set_values( self.owner_id = owner_id @classmethod - def from_response(cls, resp, ns) -> List["FlowItem"]: + def from_response(cls, resp, ns) -> list["FlowItem"]: all_flow_items = list() parsed_response = fromstring(resp) all_flow_xml = parsed_response.findall(".//t:flow", namespaces=ns) diff --git a/tableauserverclient/models/flow_run_item.py b/tableauserverclient/models/flow_run_item.py index 12281f4f8..f2f1d561f 100644 --- a/tableauserverclient/models/flow_run_item.py +++ b/tableauserverclient/models/flow_run_item.py @@ -1,13 +1,13 @@ import itertools from datetime import datetime -from typing import Dict, List, Optional, Type +from typing import Optional from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -class FlowRunItem(object): +class FlowRunItem: def __init__(self) -> None: self._id: str = "" self._flow_id: Optional[str] = None @@ -71,7 +71,7 @@ def _set_values( self._background_job_id = background_job_id @classmethod - def from_response(cls: Type["FlowRunItem"], resp: bytes, ns: Optional[Dict]) -> List["FlowRunItem"]: + def from_response(cls: type["FlowRunItem"], resp: bytes, ns: Optional[dict]) -> list["FlowRunItem"]: all_flowrun_items = list() parsed_response = fromstring(resp) all_flowrun_xml = itertools.chain( diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 6c8f7eb01..6871f8b16 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -1,4 +1,4 @@ -from typing import Callable, List, Optional, TYPE_CHECKING +from typing import Callable, Optional, TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -11,7 +11,7 @@ from tableauserverclient.server import Pager -class GroupItem(object): +class GroupItem: tag_name: str = "group" class LicenseMode: @@ -27,7 +27,7 @@ def __init__(self, name=None, domain_name=None) -> None: self.domain_name: Optional[str] = domain_name def __repr__(self): - return "{}({!r})".format(self.__class__.__name__, self.__dict__) + return f"{self.__class__.__name__}({self.__dict__!r})" @property def domain_name(self) -> Optional[str]: @@ -79,7 +79,7 @@ def _set_users(self, users: Callable[..., "Pager"]) -> None: self._users = users @classmethod - def from_response(cls, resp, ns) -> List["GroupItem"]: + def from_response(cls, resp, ns) -> list["GroupItem"]: all_group_items = list() parsed_response = fromstring(resp) all_group_xml = parsed_response.findall(".//t:group", namespaces=ns) diff --git a/tableauserverclient/models/groupset_item.py b/tableauserverclient/models/groupset_item.py index ffb57adf5..aa653a79e 100644 --- a/tableauserverclient/models/groupset_item.py +++ b/tableauserverclient/models/groupset_item.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Optional +from typing import Optional import xml.etree.ElementTree as ET from defusedxml.ElementTree import fromstring @@ -13,7 +13,7 @@ class GroupSetItem: def __init__(self, name: Optional[str] = None) -> None: self.name = name self.id: Optional[str] = None - self.groups: List["GroupItem"] = [] + self.groups: list["GroupItem"] = [] self.group_count: int = 0 def __str__(self) -> str: @@ -25,13 +25,13 @@ def __repr__(self) -> str: return self.__str__() @classmethod - def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["GroupSetItem"]: + def from_response(cls, response: bytes, ns: dict[str, str]) -> list["GroupSetItem"]: parsed_response = fromstring(response) all_groupset_xml = parsed_response.findall(".//t:groupSet", namespaces=ns) return [cls.from_xml(xml, ns) for xml in all_groupset_xml] @classmethod - def from_xml(cls, groupset_xml: ET.Element, ns: Dict[str, str]) -> "GroupSetItem": + def from_xml(cls, groupset_xml: ET.Element, ns: dict[str, str]) -> "GroupSetItem": def get_group(group_xml: ET.Element) -> GroupItem: group_item = GroupItem() group_item._id = group_xml.get("id") diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 444674e19..d7cf891cc 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -1,7 +1,7 @@ from .property_decorators import property_is_valid_time, property_not_nullable -class IntervalItem(object): +class IntervalItem: class Frequency: Hourly = "Hourly" Daily = "Daily" @@ -25,7 +25,7 @@ class Day: LastDay = "LastDay" -class HourlyInterval(object): +class HourlyInterval: def __init__(self, start_time, end_time, interval_value): self.start_time = start_time self.end_time = end_time @@ -73,12 +73,12 @@ def interval(self, intervals): for interval in intervals: # if an hourly interval is a string, then it is a weekDay interval if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): - error = "Invalid weekDay interval {}".format(interval) + error = f"Invalid weekDay interval {interval}" raise ValueError(error) # if an hourly interval is a number, it is an hours or minutes interval if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS: - error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) + error = f"Invalid interval {interval} not in {str(VALID_INTERVALS)}" raise ValueError(error) self._interval = intervals @@ -108,7 +108,7 @@ def _interval_type_pairs(self): return interval_type_pairs -class DailyInterval(object): +class DailyInterval: def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -141,12 +141,12 @@ def interval(self, intervals): for interval in intervals: # if an hourly interval is a string, then it is a weekDay interval if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): - error = "Invalid weekDay interval {}".format(interval) + error = f"Invalid weekDay interval {interval}" raise ValueError(error) # if an hourly interval is a number, it is an hours or minutes interval if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS: - error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) + error = f"Invalid interval {interval} not in {str(VALID_INTERVALS)}" raise ValueError(error) self._interval = intervals @@ -176,7 +176,7 @@ def _interval_type_pairs(self): return interval_type_pairs -class WeeklyInterval(object): +class WeeklyInterval: def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -213,7 +213,7 @@ def _interval_type_pairs(self): return [(IntervalItem.Occurrence.WeekDay, day) for day in self.interval] -class MonthlyInterval(object): +class MonthlyInterval: def __init__(self, start_time, interval_value): self.start_time = start_time diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 155ce668b..cc7cd5811 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,5 +1,5 @@ import datetime -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -7,7 +7,7 @@ from tableauserverclient.models.flow_run_item import FlowRunItem -class JobItem(object): +class JobItem: class FinishCode: """ Status codes as documented on @@ -27,7 +27,7 @@ def __init__( started_at: Optional[datetime.datetime] = None, completed_at: Optional[datetime.datetime] = None, finish_code: int = 0, - notes: Optional[List[str]] = None, + notes: Optional[list[str]] = None, mode: Optional[str] = None, workbook_id: Optional[str] = None, datasource_id: Optional[str] = None, @@ -43,7 +43,7 @@ def __init__( self._started_at = started_at self._completed_at = completed_at self._finish_code = finish_code - self._notes: List[str] = notes or [] + self._notes: list[str] = notes or [] self._mode = mode self._workbook_id = workbook_id self._datasource_id = datasource_id @@ -81,7 +81,7 @@ def finish_code(self) -> int: return self._finish_code @property - def notes(self) -> List[str]: + def notes(self) -> list[str]: return self._notes @property @@ -139,7 +139,7 @@ def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @classmethod - def from_response(cls, xml, ns) -> List["JobItem"]: + def from_response(cls, xml, ns) -> list["JobItem"]: parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:job", namespaces=ns) @@ -191,7 +191,7 @@ def _parse_element(cls, element, ns): ) -class BackgroundJobItem(object): +class BackgroundJobItem: class Status: Pending: str = "Pending" InProgress: str = "InProgress" @@ -270,7 +270,7 @@ def priority(self) -> int: return self._priority @classmethod - def from_response(cls, xml, ns) -> List["BackgroundJobItem"]: + def from_response(cls, xml, ns) -> list["BackgroundJobItem"]: parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:backgroundJob", namespaces=ns) return [cls._parse_element(x, ns) for x in all_tasks_xml] diff --git a/tableauserverclient/models/linked_tasks_item.py b/tableauserverclient/models/linked_tasks_item.py index ae9b60425..14a0e4978 100644 --- a/tableauserverclient/models/linked_tasks_item.py +++ b/tableauserverclient/models/linked_tasks_item.py @@ -1,5 +1,5 @@ import datetime as dt -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -14,7 +14,7 @@ def __init__(self) -> None: self.schedule: Optional[ScheduleItem] = None @classmethod - def from_response(cls, resp: bytes, namespace) -> List["LinkedTaskItem"]: + def from_response(cls, resp: bytes, namespace) -> list["LinkedTaskItem"]: parsed_response = fromstring(resp) return [ cls._parse_element(x, namespace) @@ -35,10 +35,10 @@ def __init__(self) -> None: self.id: Optional[str] = None self.step_number: Optional[int] = None self.stop_downstream_on_failure: Optional[bool] = None - self.task_details: List[LinkedTaskFlowRunItem] = [] + self.task_details: list[LinkedTaskFlowRunItem] = [] @classmethod - def from_task_xml(cls, xml, namespace) -> List["LinkedTaskStepItem"]: + def from_task_xml(cls, xml, namespace) -> list["LinkedTaskStepItem"]: return [cls._parse_element(x, namespace) for x in xml.findall(".//t:linkedTaskSteps[@id]", namespace)] @classmethod @@ -61,7 +61,7 @@ def __init__(self) -> None: self.flow_name: Optional[str] = None @classmethod - def _parse_element(cls, xml, namespace) -> List["LinkedTaskFlowRunItem"]: + def _parse_element(cls, xml, namespace) -> list["LinkedTaskFlowRunItem"]: all_tasks = [] for flow_run in xml.findall(".//t:flowRun[@id]", namespace): task = cls() diff --git a/tableauserverclient/models/metric_item.py b/tableauserverclient/models/metric_item.py index d8ba8e825..432fd861a 100644 --- a/tableauserverclient/models/metric_item.py +++ b/tableauserverclient/models/metric_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from datetime import datetime -from typing import List, Optional, Set +from typing import Optional from tableauserverclient.datetime_helpers import parse_datetime from .property_decorators import property_is_boolean, property_is_datetime @@ -8,7 +8,7 @@ from .permissions_item import Permission -class MetricItem(object): +class MetricItem: def __init__(self, name: Optional[str] = None): self._id: Optional[str] = None self._name: Optional[str] = name @@ -21,8 +21,8 @@ def __init__(self, name: Optional[str] = None): self._project_name: Optional[str] = None self._owner_id: Optional[str] = None self._view_id: Optional[str] = None - self._initial_tags: Set[str] = set() - self.tags: Set[str] = set() + self._initial_tags: set[str] = set() + self.tags: set[str] = set() self._permissions: Optional[Permission] = None @property @@ -126,7 +126,7 @@ def from_response( cls, resp: bytes, ns, - ) -> List["MetricItem"]: + ) -> list["MetricItem"]: all_metric_items = list() parsed_response = ET.fromstring(resp) all_metric_xml = parsed_response.findall(".//t:metric", namespaces=ns) diff --git a/tableauserverclient/models/pagination_item.py b/tableauserverclient/models/pagination_item.py index 8cebd1c86..f30519be5 100644 --- a/tableauserverclient/models/pagination_item.py +++ b/tableauserverclient/models/pagination_item.py @@ -1,7 +1,7 @@ from defusedxml.ElementTree import fromstring -class PaginationItem(object): +class PaginationItem: def __init__(self): self._page_number = None self._page_size = None diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 26f4ee7e8..bb3487279 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -1,5 +1,5 @@ import xml.etree.ElementTree as ET -from typing import Dict, List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -36,23 +36,25 @@ 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 "" class PermissionsRule: - def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> None: + def __init__(self, grantee: ResourceReference, capabilities: dict[str, str]) -> None: self.grantee = grantee self.capabilities = capabilities def __repr__(self): - return "".format(self.grantee, self.capabilities) + return f"" def __eq__(self, other: object) -> bool: if not hasattr(other, "grantee") or not hasattr(other, "capabilities"): @@ -66,7 +68,7 @@ def __and__(self, other: "PermissionsRule") -> "PermissionsRule": if self.capabilities == other.capabilities: return self - capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) + capabilities = {*self.capabilities.keys(), *other.capabilities.keys()} new_capabilities = {} for capability in capabilities: if (self.capabilities.get(capability), other.capabilities.get(capability)) == ( @@ -86,7 +88,7 @@ def __or__(self, other: "PermissionsRule") -> "PermissionsRule": if self.capabilities == other.capabilities: return self - capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) + capabilities = {*self.capabilities.keys(), *other.capabilities.keys()} new_capabilities = {} for capability in capabilities: if Permission.Mode.Allow in (self.capabilities.get(capability), other.capabilities.get(capability)): @@ -100,14 +102,14 @@ def __or__(self, other: "PermissionsRule") -> "PermissionsRule": return PermissionsRule(self.grantee, new_capabilities) @classmethod - def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: + def from_response(cls, resp, ns=None) -> list["PermissionsRule"]: parsed_response = fromstring(resp) rules = [] permissions_rules_list_xml = parsed_response.findall(".//t:granteeCapabilities", namespaces=ns) for grantee_capability_xml in permissions_rules_list_xml: - capability_dict: Dict[str, str] = {} + capability_dict: dict[str, str] = {} grantee = PermissionsRule._parse_grantee_element(grantee_capability_xml, ns) @@ -116,7 +118,7 @@ def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: mode = capability_xml.get("mode") if name is None or mode is None: - logger.error("Capability was not valid: {}".format(capability_xml)) + logger.error(f"Capability was not valid: {capability_xml}") raise UnpopulatedPropertyError() else: capability_dict[name] = mode @@ -127,7 +129,7 @@ def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: return rules @staticmethod - def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict[str, str]]) -> ResourceReference: + def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[dict[str, str]]) -> ResourceReference: """Use Xpath magic and some string splitting to get the right object type from the xml""" # Get the first element in the tree with an 'id' attribute @@ -146,6 +148,6 @@ def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict elif grantee_type == "groupSet": grantee = GroupSetItem.as_reference(grantee_id) else: - raise UnknownGranteeTypeError("No support for grantee type of {}".format(grantee_type)) + raise UnknownGranteeTypeError(f"No support for grantee type of {grantee_type}") return grantee diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 9fb382885..48f27c60c 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,6 +1,6 @@ import logging import xml.etree.ElementTree as ET -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -8,14 +8,16 @@ from tableauserverclient.models.property_decorators import property_is_enum, property_not_empty -class ProjectItem(object): +class ProjectItem: + ERROR_MSG = "Project item must be populated with permissions first." + class ContentPermissions: LockedToProject: str = "LockedToProject" ManagedByOwner: str = "ManagedByOwner" LockedToProjectWithoutNested: str = "LockedToProjectWithoutNested" def __repr__(self): - return "".format( + return "".format( self._id, self.name, self.parent_id or "None (Top level)", self.content_permissions or "Not Set" ) @@ -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 @@ -158,7 +174,7 @@ def _set_permissions(self, permissions): self._permissions = permissions def _set_default_permissions(self, permissions, content_type): - attr = "_default_{content}_permissions".format(content=content_type) + attr = f"_default_{content_type}_permissions" setattr( self, attr, @@ -166,7 +182,7 @@ def _set_default_permissions(self, permissions, content_type): ) @classmethod - def from_response(cls, resp, ns) -> List["ProjectItem"]: + def from_response(cls, resp, ns) -> list["ProjectItem"]: all_project_items = list() parsed_response = fromstring(resp) all_project_xml = parsed_response.findall(".//t:project", namespaces=ns) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index ce31b1428..5048b3498 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,7 +1,8 @@ import datetime import re from functools import wraps -from typing import Any, Container, Optional, Tuple +from typing import Any, Optional +from collections.abc import Container from tableauserverclient.datetime_helpers import parse_datetime @@ -11,7 +12,7 @@ def property_type_decorator(func): @wraps(func) def wrapper(self, value): if value is not None and not hasattr(enum_type, value): - error = "Invalid value: {0}. {1} must be of type {2}.".format(value, func.__name__, enum_type.__name__) + error = f"Invalid value: {value}. {func.__name__} must be of type {enum_type.__name__}." raise ValueError(error) return func(self, value) @@ -24,7 +25,7 @@ def property_is_boolean(func): @wraps(func) def wrapper(self, value): if not isinstance(value, bool): - error = "Boolean expected for {0} flag.".format(func.__name__) + error = f"Boolean expected for {func.__name__} flag." raise ValueError(error) return func(self, value) @@ -35,7 +36,7 @@ def property_not_nullable(func): @wraps(func) def wrapper(self, value): if value is None: - error = "{0} must be defined.".format(func.__name__) + error = f"{func.__name__} must be defined." raise ValueError(error) return func(self, value) @@ -46,7 +47,7 @@ def property_not_empty(func): @wraps(func) def wrapper(self, value): if not value: - error = "{0} must not be empty.".format(func.__name__) + error = f"{func.__name__} must not be empty." raise ValueError(error) return func(self, value) @@ -66,7 +67,7 @@ def wrapper(self, value): return wrapper -def property_is_int(range: Tuple[int, int], allowed: Optional[Container[Any]] = None): +def property_is_int(range: tuple[int, int], allowed: Optional[Container[Any]] = None): """Takes a range of ints and a list of exemptions to check against when setting a property on a model. The range is a tuple of (min, max) and the allowed list (empty by default) allows values outside that range. @@ -81,7 +82,7 @@ def property_is_int(range: Tuple[int, int], allowed: Optional[Container[Any]] = def property_type_decorator(func): @wraps(func) def wrapper(self, value): - error = "Invalid property defined: '{}'. Integer value expected.".format(value) + error = f"Invalid property defined: '{value}'. Integer value expected." if range is None: if isinstance(value, int): @@ -133,7 +134,7 @@ def wrapper(self, value): return func(self, value) if not isinstance(value, str): raise ValueError( - "Cannot convert {} into a datetime, cannot update {}".format(value.__class__.__name__, func.__name__) + f"Cannot convert {value.__class__.__name__} into a datetime, cannot update {func.__name__}" ) dt = parse_datetime(value) @@ -146,11 +147,11 @@ def property_is_data_acceleration_config(func): @wraps(func) def wrapper(self, value): if not isinstance(value, dict): - raise ValueError("{} is not type 'dict', cannot update {})".format(value.__class__.__name__, func.__name__)) + raise ValueError(f"{value.__class__.__name__} is not type 'dict', cannot update {func.__name__})") if len(value) < 2 or not all(attr in value.keys() for attr in ("acceleration_enabled", "accelerate_now")): - error = "{} should have 2 keys ".format(func.__name__) + error = f"{func.__name__} should have 2 keys " error += "'acceleration_enabled' and 'accelerate_now'" - error += "instead you have {}".format(value.keys()) + error += f"instead you have {value.keys()}" raise ValueError(error) return func(self, value) diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py index 710548fcc..4c1fff564 100644 --- a/tableauserverclient/models/reference_item.py +++ b/tableauserverclient/models/reference_item.py @@ -1,10 +1,10 @@ -class ResourceReference(object): +class ResourceReference: def __init__(self, id_, tag_name): self.id = id_ self.tag_name = tag_name def __str__(self): - return "".format(self._id, self._tag_name) + return f"" __repr__ = __str__ diff --git a/tableauserverclient/models/revision_item.py b/tableauserverclient/models/revision_item.py index a0e6a1bd5..1b4cc6249 100644 --- a/tableauserverclient/models/revision_item.py +++ b/tableauserverclient/models/revision_item.py @@ -1,12 +1,12 @@ from datetime import datetime -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -class RevisionItem(object): +class RevisionItem: def __init__(self): self._resource_id: Optional[str] = None self._resource_name: Optional[str] = None @@ -56,7 +56,7 @@ def __repr__(self): ) @classmethod - def from_response(cls, resp: bytes, ns, resource_item) -> List["RevisionItem"]: + def from_response(cls, resp: bytes, ns, resource_item) -> list["RevisionItem"]: all_revision_items = list() parsed_response = fromstring(resp) all_revision_xml = parsed_response.findall(".//t:revision", namespaces=ns) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index e416643ba..e39042058 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -19,7 +19,7 @@ Interval = Union[HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval] -class ScheduleItem(object): +class ScheduleItem: class Type: Extract = "Extract" Flow = "Flow" @@ -336,7 +336,7 @@ def parse_add_to_schedule_response(response, ns): all_task_xml = parsed_response.findall(".//t:task", namespaces=ns) error = ( - "Status {}: {}".format(response.status_code, response.reason) + f"Status {response.status_code}: {response.reason}" if response.status_code < 200 or response.status_code >= 300 else None ) diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index 57fc51af9..b13f26740 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -6,7 +6,29 @@ from tableauserverclient.helpers.logging import logger -class ServerInfoItem(object): +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("Unexpected response for ServerInfo: {}".format(resp)) - logger.info(error) + logger.exception(f"Unexpected response for ServerInfo: {resp}") return cls("Unknown", "Unknown", "Unknown") except Exception as error: - logger.info("Unexpected response for ServerInfo: {}".format(resp)) - logger.info(error) - return cls("Unknown", "Unknown", "Unknown") + 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 b651e5773..e4e146f9c 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -14,13 +14,79 @@ VALID_CONTENT_URL_RE = r"^[a-zA-Z0-9_\-]*$" -from typing import List, Optional, Union, TYPE_CHECKING +from typing import Optional, Union, TYPE_CHECKING if TYPE_CHECKING: from tableauserverclient.server import Server -class SiteItem(object): +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 @@ -873,7 +939,7 @@ def _set_values( self.auto_suspend_refresh_inactivity_window = auto_suspend_refresh_inactivity_window @classmethod - def from_response(cls, resp, ns) -> List["SiteItem"]: + def from_response(cls, resp, ns) -> list["SiteItem"]: all_site_items = list() parsed_response = fromstring(resp) all_site_xml = parsed_response.findall(".//t:site", namespaces=ns) diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index e96fcc448..61c75e2d6 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -1,4 +1,4 @@ -from typing import List, Type, TYPE_CHECKING +from typing import TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -10,7 +10,7 @@ from .target import Target -class SubscriptionItem(object): +class SubscriptionItem: def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None: self._id = None self.attach_image = True @@ -79,7 +79,7 @@ def suspended(self, value: bool) -> None: self._suspended = value @classmethod - def from_response(cls: Type, xml: bytes, ns) -> List["SubscriptionItem"]: + def from_response(cls: type, xml: bytes, ns) -> list["SubscriptionItem"]: parsed_response = fromstring(xml) all_subscriptions_xml = parsed_response.findall(".//t:subscription", namespaces=ns) diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index f9df8a8f3..0afdd4df3 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -4,7 +4,7 @@ from .property_decorators import property_not_empty, property_is_boolean -class TableItem(object): +class TableItem: def __init__(self, name, description=None): self._id = None self.description = description diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 10cf58723..7d7981433 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -1,5 +1,5 @@ import abc -from typing import Dict, Optional +from typing import Optional class Credentials(abc.ABC): @@ -9,7 +9,7 @@ def __init__(self, site_id: Optional[str] = None, user_id_to_impersonate: Option @property @abc.abstractmethod - def credentials(self) -> Dict[str, str]: + def credentials(self) -> dict[str, str]: credentials = ( "Credentials can be username/password, Personal Access Token, or JWT" "This method returns values to set as an attribute on the credentials element of the request" @@ -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: @@ -42,7 +79,7 @@ def __init__( self.username = username @property - def credentials(self) -> Dict[str, str]: + def credentials(self) -> dict[str, str]: return {"name": self.username, "password": self.password} def __repr__(self): @@ -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, @@ -69,7 +143,7 @@ def __init__( self.personal_access_token = personal_access_token @property - def credentials(self) -> Dict[str, str]: + def credentials(self) -> dict[str, str]: return { "personalAccessTokenName": self.token_name, "personalAccessTokenSecret": self.personal_access_token, @@ -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") @@ -95,7 +205,7 @@ def __init__(self, jwt: str, site_id: Optional[str] = None, user_id_to_impersona self.jwt = jwt @property - def credentials(self) -> Dict[str, str]: + def credentials(self) -> dict[str, str]: return {"jwt": self.jwt} def __repr__(self): diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index bac072076..01ee3d3a9 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -28,8 +28,8 @@ 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: - return "{}s".format(content_type) + return f"{content_type}s" diff --git a/tableauserverclient/models/tag_item.py b/tableauserverclient/models/tag_item.py index afa0a0762..cde755f05 100644 --- a/tableauserverclient/models/tag_item.py +++ b/tableauserverclient/models/tag_item.py @@ -1,16 +1,15 @@ import xml.etree.ElementTree as ET -from typing import Set from defusedxml.ElementTree import fromstring -class TagItem(object): +class TagItem: @classmethod - def from_response(cls, resp: bytes, ns) -> Set[str]: + def from_response(cls, resp: bytes, ns) -> set[str]: return cls.from_xml_element(fromstring(resp), ns) @classmethod - def from_xml_element(cls, parsed_response: ET.Element, ns) -> Set[str]: + def from_xml_element(cls, parsed_response: ET.Element, ns) -> set[str]: all_tags = set() tag_elem = parsed_response.findall(".//t:tag", namespaces=ns) for tag_xml in tag_elem: diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 01cfcfb11..fa6f782ba 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -8,7 +8,7 @@ from tableauserverclient.models.target import Target -class TaskItem(object): +class TaskItem: class Type: ExtractRefresh = "extractRefresh" DataAcceleration = "dataAcceleration" @@ -48,9 +48,9 @@ def __repr__(self) -> str: ) @classmethod - def from_response(cls, xml, ns, task_type=Type.ExtractRefresh) -> List["TaskItem"]: + def from_response(cls, xml, ns, task_type=Type.ExtractRefresh) -> list["TaskItem"]: parsed_response = fromstring(xml) - all_tasks_xml = parsed_response.findall(".//t:task/t:{}".format(task_type), namespaces=ns) + all_tasks_xml = parsed_response.findall(f".//t:task/t:{task_type}", namespaces=ns) all_tasks = (TaskItem._parse_element(x, ns) for x in all_tasks_xml) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index fe659575a..365e44c1d 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -2,7 +2,7 @@ import xml.etree.ElementTree as ET from datetime import datetime from enum import IntEnum -from typing import Dict, List, Optional, TYPE_CHECKING, Tuple +from typing import Optional, TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -18,10 +18,35 @@ from tableauserverclient.server import Pager -class UserItem(object): +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" @@ -57,7 +87,7 @@ def __init__( self._id: Optional[str] = None self._last_login: Optional[datetime] = None self._workbooks = None - self._favorites: Optional[Dict[str, List]] = None + self._favorites: Optional[dict[str, list]] = None self._groups = None self.email: Optional[str] = None self.fullname: Optional[str] = None @@ -69,7 +99,7 @@ def __init__( def __str__(self) -> str: str_site_role = self.site_role or "None" - return "".format(self.id, self.name, str_site_role) + return f"" def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @@ -141,7 +171,7 @@ def workbooks(self) -> "Pager": return self._workbooks() @property - def favorites(self) -> Dict[str, List]: + def favorites(self) -> dict[str, list]: if self._favorites is None: error = "User item must be populated with favorites first." raise UnpopulatedPropertyError(error) @@ -210,12 +240,12 @@ def _set_values( self._domain_name = domain_name @classmethod - def from_response(cls, resp, ns) -> List["UserItem"]: + def from_response(cls, resp, ns) -> list["UserItem"]: element_name = ".//t:user" return cls._parse_xml(element_name, resp, ns) @classmethod - def from_response_as_owner(cls, resp, ns) -> List["UserItem"]: + def from_response_as_owner(cls, resp, ns) -> list["UserItem"]: element_name = ".//t:owner" return cls._parse_xml(element_name, resp, ns) @@ -283,7 +313,7 @@ def _parse_element(user_xml, ns): domain_name, ) - class CSVImport(object): + class CSVImport: """ This class includes hardcoded options and logic for the CSV file format defined for user import https://help.tableau.com/current/server/en-us/users_import.htm @@ -308,7 +338,7 @@ def create_user_from_line(line: str): if line is None or line is False or line == "\n" or line == "": return None line = line.strip().lower() - values: List[str] = list(map(str.strip, line.split(","))) + values: list[str] = list(map(str.strip, line.split(","))) user = UserItem(values[UserItem.CSVImport.ColumnType.USERNAME]) if len(values) > 1: if len(values) > UserItem.CSVImport.ColumnType.MAX: @@ -337,7 +367,7 @@ def create_user_from_line(line: str): # Read through an entire CSV file meant for user import # Return the number of valid lines and a list of all the invalid lines @staticmethod - def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> Tuple[int, List[str]]: + def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, list[str]]: num_valid_lines = 0 invalid_lines = [] csv_file.seek(0) # set to start of file in case it has been read earlier @@ -345,11 +375,11 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> Tuple[int, L while line and line != "": try: # do not print passwords - logger.info("Reading user {}".format(line[:4])) + logger.info(f"Reading user {line[:4]}") UserItem.CSVImport._validate_import_line_or_throw(line, logger) num_valid_lines += 1 except Exception as exc: - logger.info("Error parsing {}: {}".format(line[:4], exc)) + logger.info(f"Error parsing {line[:4]}: {exc}") invalid_lines.append(line) line = csv_file.readline() return num_valid_lines, invalid_lines @@ -358,7 +388,7 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> Tuple[int, L # Iterate through each field and validate the given value against hardcoded constraints @staticmethod def _validate_import_line_or_throw(incoming, logger) -> None: - _valid_attributes: List[List[str]] = [ + _valid_attributes: list[list[str]] = [ [], [], [], @@ -373,23 +403,23 @@ def _validate_import_line_or_throw(incoming, logger) -> None: if len(line) > UserItem.CSVImport.ColumnType.MAX: raise AttributeError("Too many attributes in line") username = line[UserItem.CSVImport.ColumnType.USERNAME.value] - logger.debug("> details - {}".format(username)) + logger.debug(f"> details - {username}") UserItem.validate_username_or_throw(username) for i in range(1, len(line)): - logger.debug("column {}: {}".format(UserItem.CSVImport.ColumnType(i).name, line[i])) + logger.debug(f"column {UserItem.CSVImport.ColumnType(i).name}: {line[i]}") UserItem.CSVImport._validate_attribute_value( line[i], _valid_attributes[i], UserItem.CSVImport.ColumnType(i) ) # Given a restricted set of possible values, confirm the item is in that set @staticmethod - def _validate_attribute_value(item: str, possible_values: List[str], column_type) -> None: + def _validate_attribute_value(item: str, possible_values: list[str], column_type) -> None: if item is None or item == "": # value can be empty for any column except user, which is checked elsewhere return if item in possible_values or possible_values == []: return - raise AttributeError("Invalid value {} for {}".format(item, column_type)) + raise AttributeError(f"Invalid value {item} for {column_type}") # https://help.tableau.com/current/server/en-us/csvguidelines.htm#settings_and_site_roles # This logic is hardcoded to match the existing rules for import csv files diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index a26e364a3..dc5f37a48 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,7 +1,8 @@ import copy from datetime import datetime from requests import Response -from typing import Callable, Iterator, List, Optional, Set +from typing import Callable, Optional +from collections.abc import Iterator from defusedxml.ElementTree import fromstring @@ -11,13 +12,13 @@ from .tag_item import TagItem -class ViewItem(object): +class ViewItem: def __init__(self) -> None: self._content_url: Optional[str] = None self._created_at: Optional[datetime] = None self._id: Optional[str] = None self._image: Optional[Callable[[], bytes]] = None - self._initial_tags: Set[str] = set() + self._initial_tags: set[str] = set() self._name: Optional[str] = None self._owner_id: Optional[str] = None self._preview_image: Optional[Callable[[], bytes]] = None @@ -29,15 +30,15 @@ def __init__(self) -> None: self._sheet_type: Optional[str] = None self._updated_at: Optional[datetime] = None self._workbook_id: Optional[str] = None - self._permissions: Optional[Callable[[], List[PermissionsRule]]] = None - self.tags: Set[str] = set() + self._permissions: Optional[Callable[[], list[PermissionsRule]]] = None + self.tags: set[str] = set() self._data_acceleration_config = { "acceleration_enabled": None, "acceleration_status": None, } def __str__(self): - return "".format( + return "".format( self._id, self.name, self.content_url, self.project_id ) @@ -146,21 +147,21 @@ def data_acceleration_config(self, value): self._data_acceleration_config = value @property - def permissions(self) -> List[PermissionsRule]: + def permissions(self) -> list[PermissionsRule]: if self._permissions is None: error = "View item must be populated with permissions first." raise UnpopulatedPropertyError(error) return self._permissions() - def _set_permissions(self, permissions: Callable[[], List[PermissionsRule]]) -> None: + def _set_permissions(self, permissions: Callable[[], list[PermissionsRule]]) -> None: self._permissions = permissions @classmethod - def from_response(cls, resp: "Response", ns, workbook_id="") -> List["ViewItem"]: + def from_response(cls, resp: "Response", ns, workbook_id="") -> list["ViewItem"]: return cls.from_xml_element(fromstring(resp), ns, workbook_id) @classmethod - def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["ViewItem"]: + def from_xml_element(cls, parsed_response, ns, workbook_id="") -> list["ViewItem"]: all_view_items = list() all_view_xml = parsed_response.findall(".//t:view", namespaces=ns) for view_xml in all_view_xml: diff --git a/tableauserverclient/models/virtual_connection_item.py b/tableauserverclient/models/virtual_connection_item.py index 76a3b5dea..e9e22be1e 100644 --- a/tableauserverclient/models/virtual_connection_item.py +++ b/tableauserverclient/models/virtual_connection_item.py @@ -1,6 +1,7 @@ import datetime as dt import json -from typing import Callable, Dict, Iterable, List, Optional +from typing import Callable, Optional +from collections.abc import Iterable from xml.etree.ElementTree import Element from defusedxml.ElementTree import fromstring @@ -23,7 +24,7 @@ def __init__(self, name: str) -> None: self._connections: Optional[Callable[[], Iterable[ConnectionItem]]] = None self.project_id: Optional[str] = None self.owner_id: Optional[str] = None - self.content: Optional[Dict[str, dict]] = None + self.content: Optional[dict[str, dict]] = None self.certification_note: Optional[str] = None def __str__(self) -> str: @@ -40,7 +41,7 @@ def id(self) -> Optional[str]: return self._id @property - def permissions(self) -> List[PermissionsRule]: + def permissions(self) -> list[PermissionsRule]: if self._permissions is None: error = "Workbook item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -53,12 +54,12 @@ def connections(self) -> Iterable[ConnectionItem]: return self._connections() @classmethod - def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["VirtualConnectionItem"]: + def from_response(cls, response: bytes, ns: dict[str, str]) -> list["VirtualConnectionItem"]: parsed_response = fromstring(response) return [cls.from_xml(xml, ns) for xml in parsed_response.findall(".//t:virtualConnection[@name]", ns)] @classmethod - def from_xml(cls, xml: Element, ns: Dict[str, str]) -> "VirtualConnectionItem": + def from_xml(cls, xml: Element, ns: dict[str, str]) -> "VirtualConnectionItem": v_conn = cls(xml.get("name", "")) v_conn._id = xml.get("id", None) v_conn.webpage_url = xml.get("webpageUrl", None) diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py index e4d5e4aa0..98d821fb4 100644 --- a/tableauserverclient/models/webhook_item.py +++ b/tableauserverclient/models/webhook_item.py @@ -1,6 +1,6 @@ import re import xml.etree.ElementTree as ET -from typing import List, Optional, Tuple, Type +from typing import Optional from defusedxml.ElementTree import fromstring @@ -13,7 +13,7 @@ def _parse_event(events): return NAMESPACE_RE.sub("", event.tag) -class WebhookItem(object): +class WebhookItem: def __init__(self): self._id: Optional[str] = None self.name: Optional[str] = None @@ -45,10 +45,10 @@ def event(self) -> Optional[str]: @event.setter def event(self, value: str) -> None: - self._event = "webhook-source-event-{}".format(value) + self._event = f"webhook-source-event-{value}" @classmethod - def from_response(cls: Type["WebhookItem"], resp: bytes, ns) -> List["WebhookItem"]: + def from_response(cls: type["WebhookItem"], resp: bytes, ns) -> list["WebhookItem"]: all_webhooks_items = list() parsed_response = fromstring(resp) all_webhooks_xml = parsed_response.findall(".//t:webhook", namespaces=ns) @@ -61,7 +61,7 @@ def from_response(cls: Type["WebhookItem"], resp: bytes, ns) -> List["WebhookIte return all_webhooks_items @staticmethod - def _parse_element(webhook_xml: ET.Element, ns) -> Tuple: + def _parse_element(webhook_xml: ET.Element, ns) -> tuple: id = webhook_xml.get("id", None) name = webhook_xml.get("name", None) @@ -82,4 +82,4 @@ def _parse_element(webhook_xml: ET.Element, ns) -> Tuple: return id, name, url, event, owner_id def __repr__(self) -> str: - return "".format(self.id, self.name, self.url, self.event) + return f"" diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 58fd2a9a9..776d041e3 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -2,7 +2,7 @@ import datetime import uuid import xml.etree.ElementTree as ET -from typing import Callable, Dict, List, Optional, Set +from typing import Callable, Optional from defusedxml.ElementTree import fromstring @@ -20,7 +20,85 @@ from .data_freshness_policy_item import DataFreshnessPolicyItem -class WorkbookItem(object): +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 @@ -35,15 +113,15 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, self._revisions = None self._size = None self._updated_at = None - self._views: Optional[Callable[[], List[ViewItem]]] = None + self._views: Optional[Callable[[], list[ViewItem]]] = None self.name = name self._description = None self.owner_id: Optional[str] = None # workaround for Personal Space workbooks without a project self.project_id: Optional[str] = project_id or uuid.uuid4().__str__() self.show_tabs = show_tabs - self.hidden_views: Optional[List[str]] = None - self.tags: Set[str] = set() + self.hidden_views: Optional[list[str]] = None + self.tags: set[str] = set() self.data_acceleration_config = { "acceleration_enabled": None, "accelerate_now": None, @@ -56,7 +134,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, return None def __str__(self): - return "".format( + return "".format( self._id, self.name, self.content_url, self.project_id ) @@ -64,14 +142,14 @@ def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @property - def connections(self) -> List[ConnectionItem]: + def connections(self) -> list[ConnectionItem]: if self._connections is None: error = "Workbook item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self) -> List[PermissionsRule]: + def permissions(self) -> list[PermissionsRule]: if self._permissions is None: error = "Workbook item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -152,7 +230,7 @@ def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at @property - def views(self) -> List[ViewItem]: + def views(self) -> list[ViewItem]: # Views can be set in an initial workbook response OR by a call # to Server. Without getting too fancy, I think we can rely on # returning a list from the response, until they call @@ -191,7 +269,7 @@ def data_freshness_policy(self, value): self._data_freshness_policy = value @property - def revisions(self) -> List[RevisionItem]: + def revisions(self) -> list[RevisionItem]: if self._revisions is None: error = "Workbook item must be populated with revisions first." raise UnpopulatedPropertyError(error) @@ -203,7 +281,7 @@ def _set_connections(self, connections): def _set_permissions(self, permissions): self._permissions = permissions - def _set_views(self, views: Callable[[], List[ViewItem]]) -> None: + def _set_views(self, views: Callable[[], list[ViewItem]]) -> None: self._views = views def _set_pdf(self, pdf: Callable[[], bytes]) -> None: @@ -316,7 +394,7 @@ def _set_values( self.data_freshness_policy = data_freshness_policy @classmethod - def from_response(cls, resp: str, ns: Dict[str, str]) -> List["WorkbookItem"]: + def from_response(cls, resp: str, ns: dict[str, str]) -> list["WorkbookItem"]: all_workbook_items = list() parsed_response = fromstring(resp) all_workbook_xml = parsed_response.findall(".//t:workbook", namespaces=ns) diff --git a/tableauserverclient/namespace.py b/tableauserverclient/namespace.py index d225ecff6..54ac46d8d 100644 --- a/tableauserverclient/namespace.py +++ b/tableauserverclient/namespace.py @@ -11,7 +11,7 @@ class UnknownNamespaceError(Exception): pass -class Namespace(object): +class Namespace: def __init__(self): self._namespace = {"t": NEW_NAMESPACE} self._detected = False diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index f5cd1d236..87cc9460b 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 468d469a7..4211bb7ea 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -16,7 +16,7 @@ class Auth(Endpoint): - class contextmgr(object): + class contextmgr: def __init__(self, callback): self._callback = callback @@ -28,7 +28,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): @property def baseurl(self) -> str: - return "{0}/auth".format(self.parent_srv.baseurl) + return f"{self.parent_srv.baseurl}/auth" @api(version="2.0") def sign_in(self, auth_req: "Credentials") -> contextmgr: @@ -41,8 +41,32 @@ 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 = "{0}/{1}".format(self.baseurl, "signin") + url = f"{self.baseurl}/signin" signin_req = RequestFactory.Auth.signin_req(auth_req) server_response = self.parent_srv.session.post( url, data=signin_req, **self.parent_srv.http_options, allow_redirects=False @@ -63,22 +87,25 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None) self.parent_srv._set_auth(site_id, user_id, auth_token) - logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id)) + logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}") return Auth.contextmgr(self.sign_out) # We use the same request that username/password login uses for all auth types. # 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: - url = "{0}/{1}".format(self.baseurl, "signout") + """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(): return @@ -88,7 +115,34 @@ def sign_out(self) -> None: @api(version="2.6") def switch_site(self, site_item: "SiteItem") -> contextmgr: - url = "{0}/{1}".format(self.baseurl, "switchSite") + """ + 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: server_response = self.post_request(url, switch_req) @@ -104,11 +158,14 @@ def switch_site(self, site_item: "SiteItem") -> contextmgr: user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None) self.parent_srv._set_auth(site_id, user_id, auth_token) - logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id)) + logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}") return Auth.contextmgr(self.sign_out) @api(version="3.10") def revoke_all_server_admin_tokens(self) -> None: - url = "{0}/{1}".format(self.baseurl, "revokeAllServerAdminTokens") + """ + 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 57a5b0100..b02b05d78 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 List, Optional, Tuple, Union +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 @@ -33,11 +41,11 @@ class CustomViews(QuerysetEndpoint[CustomViewItem]): def __init__(self, parent_srv): - super(CustomViews, self).__init__(parent_srv) + super().__init__(parent_srv) @property def baseurl(self) -> str: - return "{0}/sites/{1}/customviews".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/customviews" @property def expurl(self) -> str: @@ -55,7 +63,7 @@ def expurl(self) -> str: """ @api(version="3.18") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[CustomViewItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[CustomViewItem], PaginationItem]: logger.info("Querying all custom views on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -68,8 +76,8 @@ def get_by_id(self, view_id: str) -> Optional[CustomViewItem]: if not view_id: error = "Custom view item missing ID." raise MissingRequiredFieldError(error) - logger.info("Querying custom view (ID: {0})".format(view_id)) - url = "{0}/{1}".format(self.baseurl, view_id) + logger.info(f"Querying custom view (ID: {view_id})") + url = f"{self.baseurl}/{view_id}" server_response = self.get_request(url) return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) @@ -83,17 +91,53 @@ def image_fetcher(): return self._get_view_image(view_item, req_options) view_item._set_image(image_fetcher) - logger.info("Populated image for custom view (ID: {0})".format(view_item.id)) + logger.info(f"Populated image for custom view (ID: {view_item.id})") def _get_view_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes: - url = "{0}/{1}/image".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}/image" server_response = self.get_request(url, req_options) 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]: @@ -105,10 +149,10 @@ def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: return view_item # Update the custom view owner or name - url = "{0}/{1}".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}" update_req = RequestFactory.CustomView.update_req(view_item) server_response = self.put_request(url, update_req) - logger.info("Updated custom view (ID: {0})".format(view_item.id)) + logger.info(f"Updated custom view (ID: {view_item.id})") return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) # Delete 1 view by id @@ -117,9 +161,9 @@ def delete(self, view_id: str) -> None: if not view_id: error = "Custom View ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, view_id) + url = f"{self.baseurl}/{view_id}" self.delete_request(url) - logger.info("Deleted single custom view (ID: {0})".format(view_id)) + logger.info(f"Deleted single custom view (ID: {view_id})") @api(version="3.21") def download(self, view_item: CustomViewItem, file: PathOrFileW) -> PathOrFileW: @@ -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/data_acceleration_report_endpoint.py b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py index 256a6e766..579001156 100644 --- a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py +++ b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py @@ -10,14 +10,14 @@ class DataAccelerationReport(Endpoint): def __init__(self, parent_srv): - super(DataAccelerationReport, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @property def baseurl(self): - return "{0}/sites/{1}/dataAccelerationReport".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/dataAccelerationReport" @api(version="3.8") def get(self, req_options=None): diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py index fd02d2e4a..ba3ecd74f 100644 --- a/tableauserverclient/server/endpoint/data_alert_endpoint.py +++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py @@ -7,7 +7,7 @@ from tableauserverclient.helpers.logging import logger -from typing import List, Optional, TYPE_CHECKING, Tuple, Union +from typing import Optional, TYPE_CHECKING, Union if TYPE_CHECKING: @@ -17,14 +17,14 @@ class DataAlerts(Endpoint): def __init__(self, parent_srv: "Server") -> None: - super(DataAlerts, self).__init__(parent_srv) + super().__init__(parent_srv) @property def baseurl(self) -> str: - return "{0}/sites/{1}/dataAlerts".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/dataAlerts" @api(version="3.2") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[DataAlertItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[DataAlertItem], PaginationItem]: logger.info("Querying all dataAlerts on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -38,8 +38,8 @@ def get_by_id(self, dataAlert_id: str) -> DataAlertItem: if not dataAlert_id: error = "dataAlert ID undefined." raise ValueError(error) - logger.info("Querying single dataAlert (ID: {0})".format(dataAlert_id)) - url = "{0}/{1}".format(self.baseurl, dataAlert_id) + logger.info(f"Querying single dataAlert (ID: {dataAlert_id})") + url = f"{self.baseurl}/{dataAlert_id}" server_response = self.get_request(url) return DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -55,9 +55,9 @@ def delete(self, dataAlert: Union[DataAlertItem, str]) -> None: error = "Dataalert ID undefined." raise ValueError(error) # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id - url = "{0}/{1}".format(self.baseurl, dataAlert_id) + url = f"{self.baseurl}/{dataAlert_id}" self.delete_request(url) - logger.info("Deleted single dataAlert (ID: {0})".format(dataAlert_id)) + logger.info(f"Deleted single dataAlert (ID: {dataAlert_id})") @api(version="3.2") def delete_user_from_alert(self, dataAlert: Union[DataAlertItem, str], user: Union[UserItem, str]) -> None: @@ -80,9 +80,9 @@ def delete_user_from_alert(self, dataAlert: Union[DataAlertItem, str], user: Uni error = "User ID undefined." raise ValueError(error) # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id - url = "{0}/{1}/users/{2}".format(self.baseurl, dataAlert_id, user_id) + url = f"{self.baseurl}/{dataAlert_id}/users/{user_id}" self.delete_request(url) - logger.info("Deleted User (ID {0}) from dataAlert (ID: {1})".format(user_id, dataAlert_id)) + logger.info(f"Deleted User (ID {user_id}) from dataAlert (ID: {dataAlert_id})") @api(version="3.2") def add_user_to_alert(self, dataAlert_item: DataAlertItem, user: Union[UserItem, str]) -> UserItem: @@ -98,10 +98,10 @@ def add_user_to_alert(self, dataAlert_item: DataAlertItem, user: Union[UserItem, if not user_id: error = "User ID undefined." raise ValueError(error) - url = "{0}/{1}/users".format(self.baseurl, dataAlert_item.id) + url = f"{self.baseurl}/{dataAlert_item.id}/users" update_req = RequestFactory.DataAlert.add_user_to_alert(dataAlert_item, user_id) server_response = self.post_request(url, update_req) - logger.info("Added user (ID {0}) to dataAlert item (ID: {1})".format(user_id, dataAlert_item.id)) + logger.info(f"Added user (ID {user_id}) to dataAlert item (ID: {dataAlert_item.id})") added_user = UserItem.from_response(server_response.content, self.parent_srv.namespace)[0] return added_user @@ -111,9 +111,9 @@ def update(self, dataAlert_item: DataAlertItem) -> DataAlertItem: error = "Dataalert item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, dataAlert_item.id) + url = f"{self.baseurl}/{dataAlert_item.id}" update_req = RequestFactory.DataAlert.update_req(dataAlert_item) server_response = self.put_request(url, update_req) - logger.info("Updated dataAlert item (ID: {0})".format(dataAlert_item.id)) + logger.info(f"Updated dataAlert item (ID: {dataAlert_item.id})") updated_dataAlert = DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_dataAlert diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 2f8fece07..c0e106eb2 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -1,5 +1,6 @@ import logging -from typing import Union, Iterable, Set +from typing import Union +from collections.abc import Iterable from tableauserverclient.server.endpoint.default_permissions_endpoint import _DefaultPermissionsEndpoint from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint @@ -15,7 +16,7 @@ class Databases(Endpoint, TaggingMixin): def __init__(self, parent_srv): - super(Databases, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @@ -23,7 +24,7 @@ def __init__(self, parent_srv): @property def baseurl(self): - return "{0}/sites/{1}/databases".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/databases" @api(version="3.5") def get(self, req_options=None): @@ -40,8 +41,8 @@ def get_by_id(self, database_id): if not database_id: error = "database ID undefined." raise ValueError(error) - logger.info("Querying single database (ID: {0})".format(database_id)) - url = "{0}/{1}".format(self.baseurl, database_id) + logger.info(f"Querying single database (ID: {database_id})") + url = f"{self.baseurl}/{database_id}" server_response = self.get_request(url) return DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -50,9 +51,9 @@ def delete(self, database_id): if not database_id: error = "Database ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, database_id) + url = f"{self.baseurl}/{database_id}" self.delete_request(url) - logger.info("Deleted single database (ID: {0})".format(database_id)) + logger.info(f"Deleted single database (ID: {database_id})") @api(version="3.5") def update(self, database_item): @@ -60,10 +61,10 @@ def update(self, database_item): error = "Database item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, database_item.id) + url = f"{self.baseurl}/{database_item.id}" update_req = RequestFactory.Database.update_req(database_item) server_response = self.put_request(url, update_req) - logger.info("Updated database item (ID: {0})".format(database_item.id)) + logger.info(f"Updated database item (ID: {database_item.id})") updated_database = DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_database @@ -78,10 +79,10 @@ def column_fetcher(): return self._get_tables_for_database(database_item) database_item._set_tables(column_fetcher) - logger.info("Populated tables for database (ID: {0}".format(database_item.id)) + logger.info(f"Populated tables for database (ID: {database_item.id}") def _get_tables_for_database(self, database_item): - url = "{0}/{1}/tables".format(self.baseurl, database_item.id) + url = f"{self.baseurl}/{database_item.id}/tables" server_response = self.get_request(url) tables = TableItem.from_response(server_response.content, self.parent_srv.namespace) return tables @@ -127,7 +128,7 @@ def delete_dqw(self, item): self._data_quality_warnings.clear(item) @api(version="3.9") - def add_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> Set[str]: + def add_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> set[str]: return super().add_tags(item, tags) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 7f3a47075..6bd809c28 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -6,7 +6,8 @@ from contextlib import closing from pathlib import Path -from typing import Iterable, List, Mapping, Optional, Sequence, Set, Tuple, TYPE_CHECKING, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable, Mapping, Sequence from tableauserverclient.helpers.headers import fix_filename from tableauserverclient.server.query import QuerySet @@ -22,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, @@ -57,7 +58,7 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]): def __init__(self, parent_srv: "Server") -> None: - super(Datasources, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "datasource") @@ -65,11 +66,11 @@ def __init__(self, parent_srv: "Server") -> None: @property def baseurl(self) -> str: - return "{0}/sites/{1}/datasources".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/datasources" # Get all datasources @api(version="2.0") - def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[DatasourceItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[DatasourceItem], PaginationItem]: logger.info("Querying all datasources on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -83,8 +84,8 @@ def get_by_id(self, datasource_id: str) -> DatasourceItem: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) - logger.info("Querying single datasource (ID: {0})".format(datasource_id)) - url = "{0}/{1}".format(self.baseurl, datasource_id) + logger.info(f"Querying single datasource (ID: {datasource_id})") + url = f"{self.baseurl}/{datasource_id}" server_response = self.get_request(url) return DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -99,10 +100,10 @@ def connections_fetcher(): return self._get_datasource_connections(datasource_item) datasource_item._set_connections(connections_fetcher) - logger.info("Populated connections for datasource (ID: {0})".format(datasource_item.id)) + logger.info(f"Populated connections for datasource (ID: {datasource_item.id})") def _get_datasource_connections(self, datasource_item, req_options=None): - url = "{0}/{1}/connections".format(self.baseurl, datasource_item.id) + url = f"{self.baseurl}/{datasource_item.id}/connections" server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -113,9 +114,9 @@ def delete(self, datasource_id: str) -> None: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, datasource_id) + url = f"{self.baseurl}/{datasource_id}" self.delete_request(url) - logger.info("Deleted single datasource (ID: {0})".format(datasource_id)) + logger.info(f"Deleted single datasource (ID: {datasource_id})") # Download 1 datasource by id @api(version="2.0") @@ -152,11 +153,11 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: self.update_tags(datasource_item) # Update the datasource itself - url = "{0}/{1}".format(self.baseurl, datasource_item.id) + url = f"{self.baseurl}/{datasource_item.id}" update_req = RequestFactory.Datasource.update_req(datasource_item) server_response = self.put_request(url, update_req) - logger.info("Updated datasource item (ID: {0})".format(datasource_item.id)) + logger.info(f"Updated datasource item (ID: {datasource_item.id})") updated_datasource = copy.copy(datasource_item) return updated_datasource._parse_common_elements(server_response.content, self.parent_srv.namespace) @@ -165,7 +166,7 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: def update_connection( self, datasource_item: DatasourceItem, connection_item: ConnectionItem ) -> Optional[ConnectionItem]: - url = "{0}/{1}/connections/{2}".format(self.baseurl, datasource_item.id, connection_item.id) + url = f"{self.baseurl}/{datasource_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) @@ -174,18 +175,16 @@ def update_connection( return None if len(connections) > 1: - logger.debug("Multiple connections returned ({0})".format(len(connections))) + logger.debug(f"Multiple connections returned ({len(connections)})") connection = list(filter(lambda x: x.id == connection_item.id, connections))[0] - logger.info( - "Updated datasource item (ID: {0} & connection item {1}".format(datasource_item.id, connection_item.id) - ) + logger.info(f"Updated datasource item (ID: {datasource_item.id} & connection item {connection_item.id}") return connection @api(version="2.8") def refresh(self, datasource_item: DatasourceItem) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) - url = "{0}/{1}/refresh".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}/refresh" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -194,7 +193,7 @@ def refresh(self, datasource_item: DatasourceItem) -> JobItem: @api(version="3.5") def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) - url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) + url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -203,7 +202,7 @@ def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) @api(version="3.5") def delete_extract(self, datasource_item: DatasourceItem) -> None: id_ = getattr(datasource_item, "id", datasource_item) - url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}/deleteExtract" empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @@ -223,12 +222,12 @@ def publish( if isinstance(file, (os.PathLike, str)): if not os.path.isfile(file): error = "File path does not lead to an existing file." - raise IOError(error) + raise OSError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] file_size = os.path.getsize(file) - logger.debug("Publishing file `{}`, size `{}`".format(filename, file_size)) + logger.debug(f"Publishing file `{filename}`, size `{file_size}`") # If name is not defined, grab the name from the file to publish if not datasource_item.name: datasource_item.name = os.path.splitext(filename)[0] @@ -247,10 +246,10 @@ def publish( elif file_type == "xml": file_extension = "tds" else: - error = "Unsupported file type {}".format(file_type) + error = f"Unsupported file type {file_type}" raise ValueError(error) - filename = "{}.{}".format(datasource_item.name, file_extension) + filename = f"{datasource_item.name}.{file_extension}" file_size = get_file_object_size(file) else: @@ -261,27 +260,27 @@ def publish( raise ValueError(error) # Construct the url with the defined mode - url = "{0}?datasourceType={1}".format(self.baseurl, file_extension) + url = f"{self.baseurl}?datasourceType={file_extension}" if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: - url += "&{0}=true".format(mode.lower()) + url += f"&{mode.lower()}=true" if as_job: - url += "&{0}=true".format("asJob") + 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) - url = "{0}&uploadSessionId={1}".format(url, upload_session_id) + url = f"{url}&uploadSessionId={upload_session_id}" xml_request, content_type = RequestFactory.Datasource.publish_req_chunked( datasource_item, connection_credentials, connections ) else: - logger.info("Publishing {0} to server".format(filename)) + logger.info(f"Publishing {filename} to server") if isinstance(file, (Path, str)): with open(file, "rb") as f: @@ -309,11 +308,11 @@ def publish( if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (JOB_ID: {1}".format(filename, new_job.id)) + logger.info(f"Published {filename} (JOB_ID: {new_job.id}") return new_job else: new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (ID: {1})".format(filename, new_datasource.id)) + logger.info(f"Published {filename} (ID: {new_datasource.id})") return new_datasource @api(version="3.13") @@ -327,23 +326,23 @@ def update_hyper_data( ) -> JobItem: if isinstance(datasource_or_connection_item, DatasourceItem): datasource_id = datasource_or_connection_item.id - url = "{0}/{1}/data".format(self.baseurl, datasource_id) + url = f"{self.baseurl}/{datasource_id}/data" elif isinstance(datasource_or_connection_item, ConnectionItem): datasource_id = datasource_or_connection_item.datasource_id connection_id = datasource_or_connection_item.id - url = "{0}/{1}/connections/{2}/data".format(self.baseurl, datasource_id, connection_id) + url = f"{self.baseurl}/{datasource_id}/connections/{connection_id}/data" else: assert isinstance(datasource_or_connection_item, str) - url = "{0}/{1}/data".format(self.baseurl, datasource_or_connection_item) + url = f"{self.baseurl}/{datasource_or_connection_item}/data" if payload is not None: if not os.path.isfile(payload): error = "File path does not lead to an existing file." - raise IOError(error) + raise OSError(error) - logger.info("Uploading {0} to server with chunking method for Update job".format(payload)) + logger.info(f"Uploading {payload} to server with chunking method for Update job") upload_session_id = self.parent_srv.fileuploads.upload(payload) - url = "{0}?uploadSessionId={1}".format(url, upload_session_id) + url = f"{url}?uploadSessionId={upload_session_id}" json_request = json.dumps({"actions": actions}) parameters = {"headers": {"requestid": request_id}} @@ -356,7 +355,7 @@ def populate_permissions(self, item: DatasourceItem) -> None: self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, item: DatasourceItem, permission_item: List["PermissionsRule"]) -> None: + def update_permissions(self, item: DatasourceItem, permission_item: list["PermissionsRule"]) -> None: self._permissions.update(item, permission_item) @api(version="2.0") @@ -390,12 +389,12 @@ def revisions_fetcher(): return self._get_datasource_revisions(datasource_item) datasource_item._set_revisions(revisions_fetcher) - logger.info("Populated revisions for datasource (ID: {0})".format(datasource_item.id)) + logger.info(f"Populated revisions for datasource (ID: {datasource_item.id})") def _get_datasource_revisions( self, datasource_item: DatasourceItem, req_options: Optional["RequestOptions"] = None - ) -> List[RevisionItem]: - url = "{0}/{1}/revisions".format(self.baseurl, datasource_item.id) + ) -> list[RevisionItem]: + url = f"{self.baseurl}/{datasource_item.id}/revisions" server_response = self.get_request(url, req_options) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, datasource_item) return revisions @@ -413,9 +412,9 @@ def download_revision( error = "Datasource ID undefined." raise ValueError(error) if revision_number is None: - url = "{0}/{1}/content".format(self.baseurl, datasource_id) + url = f"{self.baseurl}/{datasource_id}/content" else: - url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, datasource_id, revision_number) + url = f"{self.baseurl}/{datasource_id}/revisions/{revision_number}/content" if not include_extract: url += "?includeExtract=False" @@ -437,9 +436,7 @@ def download_revision( f.write(chunk) return_path = os.path.abspath(download_path) - logger.info( - "Downloaded datasource revision {0} to {1} (ID: {2})".format(revision_number, return_path, datasource_id) - ) + logger.info(f"Downloaded datasource revision {revision_number} to {return_path} (ID: {datasource_id})") return return_path @api(version="2.3") @@ -449,19 +446,17 @@ def delete_revision(self, datasource_id: str, revision_number: str) -> None: url = "/".join([self.baseurl, datasource_id, "revisions", revision_number]) self.delete_request(url) - logger.info( - "Deleted single datasource revision (ID: {0}) (Revision: {1})".format(datasource_id, revision_number) - ) + logger.info(f"Deleted single datasource revision (ID: {datasource_id}) (Revision: {revision_number})") # a convenience method @api(version="2.8") def schedule_extract_refresh( self, schedule_id: str, item: DatasourceItem - ) -> List["AddResponse"]: # actually should return a task + ) -> list["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item) @api(version="1.0") - def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> set[str]: return super().add_tags(item, tags) @api(version="1.0") diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 19112d713..499324e8e 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -4,7 +4,8 @@ from .exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory from tableauserverclient.models import DatabaseItem, PermissionsRule, ProjectItem, plural_type, Resource -from typing import TYPE_CHECKING, Callable, List, Optional, Sequence, Union +from typing import TYPE_CHECKING, Callable, Optional, Union +from collections.abc import Sequence if TYPE_CHECKING: from ..server import Server @@ -25,7 +26,7 @@ class _DefaultPermissionsEndpoint(Endpoint): """ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None: - super(_DefaultPermissionsEndpoint, self).__init__(parent_srv) + super().__init__(parent_srv) # owner_baseurl is the baseurl of the parent, a project or database. # It MUST be a lambda since we don't know the full site URL until we sign in. @@ -33,23 +34,25 @@ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> No self.owner_baseurl = owner_baseurl def __str__(self): - return "".format(self.owner_baseurl()) + return f"" __repr__ = __str__ def update_default_permissions( - self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Resource - ) -> List[PermissionsRule]: - url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), resource.id, plural_type(content_type)) + 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) response = self.put_request(url, update_req) permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace) - logger.info("Updated default {} permissions for resource {}".format(content_type, resource.id)) + logger.info(f"Updated default {content_type} permissions for resource {resource.id}") logger.info(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 = ( @@ -65,29 +68,27 @@ def delete_default_permission(self, resource: BaseItem, rule: PermissionsRule, c ) ) - logger.debug("Removing {0} permission for capability {1}".format(mode, capability)) + logger.debug(f"Removing {mode} permission for capability {capability}") self.delete_request(url) - logger.info( - "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) - ) + 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) - def permission_fetcher() -> List[PermissionsRule]: + def permission_fetcher() -> list[PermissionsRule]: return self._get_default_permissions(item, content_type) item._set_default_permissions(permission_fetcher, content_type) - logger.info("Populated default {0} permissions for item (ID: {1})".format(content_type, item.id)) + 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 - ) -> List[PermissionsRule]: - url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), item.id, plural_type(content_type)) + 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) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) logger.info({"content_type": content_type, "permissions": permissions}) diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index 5296523ee..90e31483b 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -10,35 +10,35 @@ class _DataQualityWarningEndpoint(Endpoint): def __init__(self, parent_srv, resource_type): - super(_DataQualityWarningEndpoint, self).__init__(parent_srv) + super().__init__(parent_srv) self.resource_type = resource_type @property def baseurl(self): - return "{0}/sites/{1}/dataQualityWarnings/{2}".format( + return "{}/sites/{}/dataQualityWarnings/{}".format( self.parent_srv.baseurl, self.parent_srv.site_id, self.resource_type ) def add(self, resource, warning): - url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) + url = f"{self.baseurl}/{resource.id}" add_req = RequestFactory.DQW.add_req(warning) response = self.post_request(url, add_req) warnings = DQWItem.from_response(response.content, self.parent_srv.namespace) - logger.info("Added dqw for resource {0}".format(resource.id)) + logger.info(f"Added dqw for resource {resource.id}") return warnings def update(self, resource, warning): - url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) + url = f"{self.baseurl}/{resource.id}" add_req = RequestFactory.DQW.update_req(warning) response = self.put_request(url, add_req) warnings = DQWItem.from_response(response.content, self.parent_srv.namespace) - logger.info("Added dqw for resource {0}".format(resource.id)) + logger.info(f"Added dqw for resource {resource.id}") return warnings def clear(self, resource): - url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) + url = f"{self.baseurl}/{resource.id}" return self.delete_request(url) def populate(self, item): @@ -50,10 +50,10 @@ def dqw_fetcher(): return self._get_data_quality_warnings(item) item._set_data_quality_warnings(dqw_fetcher) - logger.info("Populated permissions for item (ID: {0})".format(item.id)) + logger.info(f"Populated permissions for item (ID: {item.id})") def _get_data_quality_warnings(self, item, req_options=None): - url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=item.id) + url = f"{self.baseurl}/{item.id}" server_response = self.get_request(url, req_options) dqws = DQWItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index be0602df5..9e1160705 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -8,12 +8,9 @@ from typing import ( Any, Callable, - Dict, Generic, - List, Optional, TYPE_CHECKING, - Tuple, TypeVar, Union, ) @@ -22,6 +19,7 @@ from tableauserverclient.server.request_options import RequestOptions from tableauserverclient.server.endpoint.exceptions import ( + FailedSignInError, ServerResponseError, InternalServerError, NonXMLResponseError, @@ -56,7 +54,7 @@ def __init__(self, parent_srv: "Server"): async_response = None @staticmethod - def set_parameters(http_options, auth_token, content, content_type, parameters) -> Dict[str, Any]: + def set_parameters(http_options, auth_token, content, content_type, parameters) -> dict[str, Any]: parameters = parameters or {} parameters.update(http_options) if "headers" not in parameters: @@ -82,7 +80,7 @@ def set_user_agent(parameters): else: # only set the TSC user agent if not already populated _client_version: Optional[str] = get_versions()["version"] - parameters["headers"][USER_AGENT_HEADER] = "Tableau Server Client/{}".format(_client_version) + parameters["headers"][USER_AGENT_HEADER] = f"Tableau Server Client/{_client_version}" # result: parameters["headers"]["User-Agent"] is set # return explicitly for testing only @@ -90,12 +88,12 @@ def set_user_agent(parameters): def _blocking_request(self, method, url, parameters={}) -> Optional[Union["Response", Exception]]: response = None - logger.debug("[{}] Begin blocking request to {}".format(datetime.timestamp(), url)) + logger.debug(f"[{datetime.timestamp()}] Begin blocking request to {url}") try: response = method(url, **parameters) - logger.debug("[{}] Call finished".format(datetime.timestamp())) + logger.debug(f"[{datetime.timestamp()}] Call finished") except Exception as e: - logger.debug("Error making request to server: {}".format(e)) + logger.debug(f"Error making request to server: {e}") raise e return response @@ -111,13 +109,13 @@ def _make_request( content: Optional[bytes] = None, auth_token: Optional[str] = None, content_type: Optional[str] = None, - parameters: Optional[Dict[str, Any]] = None, + parameters: Optional[dict[str, Any]] = None, ) -> "Response": parameters = Endpoint.set_parameters( self.parent_srv.http_options, auth_token, content, content_type, parameters ) - logger.debug("request method {}, url: {}".format(method.__name__, url)) + logger.debug(f"request method {method.__name__}, url: {url}") if content: redacted = helpers.strings.redact_xml(content[:200]) # this needs to be under a trace or something, it's a LOT @@ -129,21 +127,21 @@ def _make_request( server_response: Optional[Union["Response", Exception]] = self.send_request_while_show_progress_threaded( method, url, parameters, request_timeout ) - logger.debug("[{}] Async request returned: received {}".format(datetime.timestamp(), server_response)) + logger.debug(f"[{datetime.timestamp()}] Async request returned: received {server_response}") # is this blocking retry really necessary? I guess if it was just the threading messing it up? if server_response is None: logger.debug(server_response) - logger.debug("[{}] Async request failed: retrying".format(datetime.timestamp())) + logger.debug(f"[{datetime.timestamp()}] Async request failed: retrying") server_response = self._blocking_request(method, url, parameters) if server_response is None: - logger.debug("[{}] Request failed".format(datetime.timestamp())) + logger.debug(f"[{datetime.timestamp()}] Request failed") raise RuntimeError if isinstance(server_response, Exception): raise server_response self._check_status(server_response, url) loggable_response = self.log_response_safely(server_response) - logger.debug("Server response from {0}".format(url)) + logger.debug(f"Server response from {url}") # uncomment the following to log full responses in debug mode # BE CAREFUL WHEN SHARING THESE RESULTS - MAY CONTAIN YOUR SENSITIVE DATA # logger.debug(loggable_response) @@ -154,16 +152,16 @@ def _make_request( return server_response def _check_status(self, server_response: "Response", url: Optional[str] = None): - logger.debug("Response status: {}".format(server_response)) + logger.debug(f"Response status: {server_response}") if not hasattr(server_response, "status_code"): - raise EnvironmentError("Response is not a http response?") + raise OSError("Response is not a http response?") if server_response.status_code >= 500: raise InternalServerError(server_response, url) elif server_response.status_code not in Success_codes: 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: @@ -183,9 +181,9 @@ def log_response_safely(self, server_response: "Response") -> str: # content-type is an octet-stream accomplishes the same goal without eagerly loading content. # This check is to determine if the response is a text response (xml or otherwise) # so that we do not attempt to log bytes and other binary data. - loggable_response = "Content type `{}`".format(content_type) + loggable_response = f"Content type `{content_type}`" if content_type == "application/octet-stream": - loggable_response = "A stream of type {} [Truncated File Contents]".format(content_type) + loggable_response = f"A stream of type {content_type} [Truncated File Contents]" elif server_response.encoding and len(server_response.content) > 0: loggable_response = helpers.strings.redact_xml(server_response.content.decode(server_response.encoding)) return loggable_response @@ -313,7 +311,7 @@ def wrapper(self: E, *args: P.args, **kwargs: P.kwargs) -> R: for p in params_to_check: min_ver = Version(str(params[p])) if server_ver < min_ver: - error = "{!r} not available in {}, it will be ignored. Added in {}".format(p, server_ver, min_ver) + error = f"{p!r} not available in {server_ver}, it will be ignored. Added in {min_ver}" warnings.warn(error) return func(self, *args, **kwargs) @@ -353,5 +351,5 @@ def paginate(self, **kwargs) -> QuerySet[T]: return queryset @abc.abstractmethod - def get(self, request_options: Optional[RequestOptions] = None) -> Tuple[List[T], PaginationItem]: + def get(self, request_options: Optional[RequestOptions] = None) -> tuple[list[T], PaginationItem]: raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}") diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 9dfd38da6..77332da3e 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -1,24 +1,31 @@ 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 self.url = url - super(ServerResponseError, self).__init__(str(self)) + super().__init__(str(self)) def __str__(self): - return "\n\n\t{0}: {1}\n\t\t{2}".format(self.code, self.summary, self.detail) + 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 @@ -40,7 +51,7 @@ def __init__(self, server_response, request_url: Optional[str] = None): self.url = request_url or "server" def __str__(self): - return "\n\nInternal error {0} at {1}\n{2}".format(self.code, self.url, self.content) + return f"\n\nInternal error {self.code} at {self.url}\n{self.content}" class MissingRequiredFieldError(TableauError): @@ -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/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 5f298f37e..8330e6d2c 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -20,13 +20,13 @@ class Favorites(Endpoint): @property def baseurl(self) -> str: - return "{0}/sites/{1}/favorites".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/favorites" # Gets all favorites @api(version="2.5") def get(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: - logger.info("Querying all favorites for user {0}".format(user_item.name)) - url = "{0}/{1}".format(self.baseurl, user_item.id) + logger.info(f"Querying all favorites for user {user_item.name}") + url = f"{self.baseurl}/{user_item.id}" server_response = self.get_request(url, req_options) user_item._favorites = FavoriteItem.from_response(server_response.content, self.parent_srv.namespace) @@ -34,53 +34,53 @@ def get(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) @api(version="3.15") def add_favorite(self, user_item: UserItem, content_type: str, item: TableauItem) -> "Response": - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_request(item.id, content_type, item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(item.name, user_item.id)) + logger.info(f"Favorited {item.name} for user (ID: {user_item.id})") return server_response @api(version="2.0") def add_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_workbook_req(workbook_item.id, workbook_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(workbook_item.name, user_item.id)) + logger.info(f"Favorited {workbook_item.name} for user (ID: {user_item.id})") @api(version="2.0") def add_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_view_req(view_item.id, view_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(view_item.name, user_item.id)) + logger.info(f"Favorited {view_item.name} for user (ID: {user_item.id})") @api(version="2.3") def add_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_datasource_req(datasource_item.id, datasource_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(datasource_item.name, user_item.id)) + logger.info(f"Favorited {datasource_item.name} for user (ID: {user_item.id})") @api(version="3.1") def add_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_project_req(project_item.id, project_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(project_item.name, user_item.id)) + logger.info(f"Favorited {project_item.name} for user (ID: {user_item.id})") @api(version="3.3") def add_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_flow_req(flow_item.id, flow_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(flow_item.name, user_item.id)) + logger.info(f"Favorited {flow_item.name} for user (ID: {user_item.id})") @api(version="3.3") def add_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_request(metric_item.id, Resource.Metric, metric_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited metric {0} for user (ID: {1})".format(metric_item.name, user_item.id)) + logger.info(f"Favorited metric {metric_item.name} for user (ID: {user_item.id})") # ------- delete from favorites # Response: @@ -94,42 +94,42 @@ def add_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> N @api(version="3.15") def delete_favorite(self, user_item: UserItem, content_type: Resource, item: TableauItem) -> None: - url = "{0}/{1}/{2}/{3}".format(self.baseurl, user_item.id, content_type, item.id) - logger.info("Removing favorite {0}({1}) for user (ID: {2})".format(content_type, item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/{content_type}/{item.id}" + logger.info(f"Removing favorite {content_type}({item.id}) for user (ID: {user_item.id})") self.delete_request(url) @api(version="2.0") def delete_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None: - url = "{0}/{1}/workbooks/{2}".format(self.baseurl, user_item.id, workbook_item.id) - logger.info("Removing favorite workbook {0} for user (ID: {1})".format(workbook_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/workbooks/{workbook_item.id}" + logger.info(f"Removing favorite workbook {workbook_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="2.0") def delete_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None: - url = "{0}/{1}/views/{2}".format(self.baseurl, user_item.id, view_item.id) - logger.info("Removing favorite view {0} for user (ID: {1})".format(view_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/views/{view_item.id}" + logger.info(f"Removing favorite view {view_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="2.3") def delete_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None: - url = "{0}/{1}/datasources/{2}".format(self.baseurl, user_item.id, datasource_item.id) - logger.info("Removing favorite {0} for user (ID: {1})".format(datasource_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/datasources/{datasource_item.id}" + logger.info(f"Removing favorite {datasource_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="3.1") def delete_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None: - url = "{0}/{1}/projects/{2}".format(self.baseurl, user_item.id, project_item.id) - logger.info("Removing favorite project {0} for user (ID: {1})".format(project_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/projects/{project_item.id}" + logger.info(f"Removing favorite project {project_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="3.3") def delete_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None: - url = "{0}/{1}/flows/{2}".format(self.baseurl, user_item.id, flow_item.id) - logger.info("Removing favorite flow {0} for user (ID: {1})".format(flow_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/flows/{flow_item.id}" + logger.info(f"Removing favorite flow {flow_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="3.15") def delete_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None: - url = "{0}/{1}/metrics/{2}".format(self.baseurl, user_item.id, metric_item.id) - logger.info("Removing favorite metric {0} for user (ID: {1})".format(metric_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/metrics/{metric_item.id}" + logger.info(f"Removing favorite metric {metric_item.id} for user (ID: {user_item.id})") self.delete_request(url) diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 0d30797c1..1ae10e72d 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -9,11 +9,11 @@ class Fileuploads(Endpoint): def __init__(self, parent_srv): - super(Fileuploads, self).__init__(parent_srv) + super().__init__(parent_srv) @property def baseurl(self): - return "{0}/sites/{1}/fileUploads".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/fileUploads" @api(version="2.0") def initiate(self): @@ -21,14 +21,14 @@ def initiate(self): server_response = self.post_request(url, "") fileupload_item = FileuploadItem.from_response(server_response.content, self.parent_srv.namespace) upload_id = fileupload_item.upload_session_id - logger.info("Initiated file upload session (ID: {0})".format(upload_id)) + logger.info(f"Initiated file upload session (ID: {upload_id})") return upload_id @api(version="2.0") def append(self, upload_id, data, content_type): - url = "{0}/{1}".format(self.baseurl, upload_id) + url = f"{self.baseurl}/{upload_id}" server_response = self.put_request(url, data, content_type) - logger.info("Uploading a chunk to session (ID: {0})".format(upload_id)) + logger.info(f"Uploading a chunk to session (ID: {upload_id})") return FileuploadItem.from_response(server_response.content, self.parent_srv.namespace) def _read_chunks(self, file): @@ -52,12 +52,10 @@ def _read_chunks(self, file): def upload(self, file): upload_id = self.initiate() for chunk in self._read_chunks(file): - logger.debug("{} processing chunk...".format(datetime.timestamp())) + logger.debug(f"{datetime.timestamp()} processing chunk...") request, content_type = RequestFactory.Fileupload.chunk_req(chunk) - logger.debug("{} created chunk request".format(datetime.timestamp())) + logger.debug(f"{datetime.timestamp()} created chunk request") fileupload_item = self.append(upload_id, request, content_type) - logger.info( - "\t{0} Published {1}MB".format(datetime.timestamp(), (fileupload_item.file_size / BYTES_PER_MB)) - ) - logger.info("File upload finished (ID: {0})".format(upload_id)) + logger.info(f"\t{datetime.timestamp()} Published {(fileupload_item.file_size / BYTES_PER_MB)}MB") + logger.info(f"File upload finished (ID: {upload_id})") return upload_id diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index c339a0645..2c3bb84bc 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -1,9 +1,9 @@ import logging -from typing import List, Optional, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING, Union from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException, FlowRunCancelledException -from tableauserverclient.models import FlowRunItem, PaginationItem +from tableauserverclient.models import FlowRunItem from tableauserverclient.exponential_backoff import ExponentialBackoffTimer from tableauserverclient.helpers.logging import logger @@ -16,22 +16,24 @@ class FlowRuns(QuerysetEndpoint[FlowRunItem]): def __init__(self, parent_srv: "Server") -> None: - super(FlowRuns, self).__init__(parent_srv) + super().__init__(parent_srv) return None @property def baseurl(self) -> str: - return "{0}/sites/{1}/flows/runs".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/flows/runs" # Get all flows @api(version="3.10") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[FlowRunItem], PaginationItem]: + # QuerysetEndpoint expects a PaginationItem to be returned, but FlowRuns + # does not return a PaginationItem. Suppressing the mypy error because the + # changes to the QuerySet class should permit this to function regardless. + def get(self, req_options: Optional["RequestOptions"] = None) -> list[FlowRunItem]: # type: ignore[override] logger.info("Querying all flow runs on site") url = self.baseurl server_response = self.get_request(url, req_options) - pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) all_flow_run_items = FlowRunItem.from_response(server_response.content, self.parent_srv.namespace) - return all_flow_run_items, pagination_item + return all_flow_run_items # Get 1 flow by id @api(version="3.10") @@ -39,21 +41,21 @@ def get_by_id(self, flow_run_id: str) -> FlowRunItem: if not flow_run_id: error = "Flow ID undefined." raise ValueError(error) - logger.info("Querying single flow (ID: {0})".format(flow_run_id)) - url = "{0}/{1}".format(self.baseurl, flow_run_id) + logger.info(f"Querying single flow (ID: {flow_run_id})") + url = f"{self.baseurl}/{flow_run_id}" server_response = self.get_request(url) return FlowRunItem.from_response(server_response.content, self.parent_srv.namespace)[0] # Cancel 1 flow run by id @api(version="3.10") - def cancel(self, flow_run_id: str) -> None: + def cancel(self, flow_run_id: Union[str, FlowRunItem]) -> None: if not flow_run_id: error = "Flow ID undefined." raise ValueError(error) id_ = getattr(flow_run_id, "id", flow_run_id) - url = "{0}/{1}".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}" self.put_request(url) - logger.info("Deleted single flow (ID: {0})".format(id_)) + logger.info(f"Deleted single flow (ID: {id_})") @api(version="3.10") def wait_for_job(self, flow_run_id: str, *, timeout: Optional[int] = None) -> FlowRunItem: @@ -69,7 +71,7 @@ def wait_for_job(self, flow_run_id: str, *, timeout: Optional[int] = None) -> Fl flow_run = self.get_by_id(flow_run_id) logger.debug(f"\tFlowRun {flow_run_id} progress={flow_run.progress}") - logger.info("FlowRun {} Completed: Status: {}".format(flow_run_id, flow_run.status)) + logger.info(f"FlowRun {flow_run_id} Completed: Status: {flow_run.status}") if flow_run.status == "Success": return flow_run diff --git a/tableauserverclient/server/endpoint/flow_task_endpoint.py b/tableauserverclient/server/endpoint/flow_task_endpoint.py index eea3f9710..9e21661e6 100644 --- a/tableauserverclient/server/endpoint/flow_task_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_task_endpoint.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional, Tuple, TYPE_CHECKING +from typing import TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import Endpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -15,7 +15,7 @@ class FlowTasks(Endpoint): @property def baseurl(self) -> str: - return "{0}/sites/{1}/tasks/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/flows" @api(version="3.22") def create(self, flow_item: TaskItem) -> TaskItem: diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 53d072f50..7eb5dc3ba 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -5,7 +5,8 @@ import os from contextlib import closing from pathlib import Path -from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable from tableauserverclient.helpers.headers import fix_filename @@ -53,18 +54,18 @@ class Flows(QuerysetEndpoint[FlowItem], TaggingMixin[FlowItem]): def __init__(self, parent_srv): - super(Flows, self).__init__(parent_srv) + super().__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "flow") @property def baseurl(self) -> str: - return "{0}/sites/{1}/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/flows" # Get all flows @api(version="3.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[FlowItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[FlowItem], PaginationItem]: logger.info("Querying all flows on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -78,8 +79,8 @@ def get_by_id(self, flow_id: str) -> FlowItem: if not flow_id: error = "Flow ID undefined." raise ValueError(error) - logger.info("Querying single flow (ID: {0})".format(flow_id)) - url = "{0}/{1}".format(self.baseurl, flow_id) + logger.info(f"Querying single flow (ID: {flow_id})") + url = f"{self.baseurl}/{flow_id}" server_response = self.get_request(url) return FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -94,10 +95,10 @@ def connections_fetcher(): return self._get_flow_connections(flow_item) flow_item._set_connections(connections_fetcher) - logger.info("Populated connections for flow (ID: {0})".format(flow_item.id)) + logger.info(f"Populated connections for flow (ID: {flow_item.id})") - def _get_flow_connections(self, flow_item, req_options: Optional["RequestOptions"] = None) -> List[ConnectionItem]: - url = "{0}/{1}/connections".format(self.baseurl, flow_item.id) + def _get_flow_connections(self, flow_item, req_options: Optional["RequestOptions"] = None) -> list[ConnectionItem]: + url = f"{self.baseurl}/{flow_item.id}/connections" server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -108,9 +109,9 @@ def delete(self, flow_id: str) -> None: if not flow_id: error = "Flow ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, flow_id) + url = f"{self.baseurl}/{flow_id}" self.delete_request(url) - logger.info("Deleted single flow (ID: {0})".format(flow_id)) + logger.info(f"Deleted single flow (ID: {flow_id})") # Download 1 flow by id @api(version="3.3") @@ -118,7 +119,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path if not flow_id: error = "Flow ID undefined." raise ValueError(error) - url = "{0}/{1}/content".format(self.baseurl, flow_id) + url = f"{self.baseurl}/{flow_id}/content" with closing(self.get_request(url, parameters={"stream": True})) as server_response: m = Message() @@ -137,7 +138,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path f.write(chunk) return_path = os.path.abspath(download_path) - logger.info("Downloaded flow to {0} (ID: {1})".format(return_path, flow_id)) + logger.info(f"Downloaded flow to {return_path} (ID: {flow_id})") return return_path # Update flow @@ -150,28 +151,28 @@ def update(self, flow_item: FlowItem) -> FlowItem: self._resource_tagger.update_tags(self.baseurl, flow_item) # Update the flow itself - url = "{0}/{1}".format(self.baseurl, flow_item.id) + url = f"{self.baseurl}/{flow_item.id}" update_req = RequestFactory.Flow.update_req(flow_item) server_response = self.put_request(url, update_req) - logger.info("Updated flow item (ID: {0})".format(flow_item.id)) + logger.info(f"Updated flow item (ID: {flow_item.id})") updated_flow = copy.copy(flow_item) return updated_flow._parse_common_elements(server_response.content, self.parent_srv.namespace) # Update flow connections @api(version="3.3") def update_connection(self, flow_item: FlowItem, connection_item: ConnectionItem) -> ConnectionItem: - url = "{0}/{1}/connections/{2}".format(self.baseurl, flow_item.id, connection_item.id) + url = f"{self.baseurl}/{flow_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Updated flow item (ID: {0} & connection item {1}".format(flow_item.id, connection_item.id)) + logger.info(f"Updated flow item (ID: {flow_item.id} & connection item {connection_item.id}") return connection @api(version="3.3") def refresh(self, flow_item: FlowItem) -> JobItem: - url = "{0}/{1}/run".format(self.baseurl, flow_item.id) + url = f"{self.baseurl}/{flow_item.id}/run" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -180,7 +181,7 @@ def refresh(self, flow_item: FlowItem) -> JobItem: # Publish flow @api(version="3.3") def publish( - self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: Optional[List[ConnectionItem]] = None + self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: Optional[list[ConnectionItem]] = None ) -> FlowItem: if not mode or not hasattr(self.parent_srv.PublishMode, mode): error = "Invalid mode defined." @@ -189,7 +190,7 @@ def publish( if isinstance(file, (str, os.PathLike)): if not os.path.isfile(file): error = "File path does not lead to an existing file." - raise IOError(error) + raise OSError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] @@ -213,30 +214,30 @@ def publish( elif file_type == "xml": file_extension = "tfl" else: - error = "Unsupported file type {}!".format(file_type) + error = f"Unsupported file type {file_type}!" raise ValueError(error) # Generate filename for file object. # This is needed when publishing the flow in a single request - filename = "{}.{}".format(flow_item.name, file_extension) + filename = f"{flow_item.name}.{file_extension}" file_size = get_file_object_size(file) else: raise TypeError("file should be a filepath or file object.") # Construct the url with the defined mode - url = "{0}?flowType={1}".format(self.baseurl, file_extension) + url = f"{self.baseurl}?flowType={file_extension}" if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: - url += "&{0}=true".format(mode.lower()) + url += f"&{mode.lower()}=true" # Determine if chunking is required (64MB is the limit for single upload method) if file_size >= FILESIZE_LIMIT: - logger.info("Publishing {0} to server with chunking method (flow over 64MB)".format(filename)) + logger.info(f"Publishing {filename} to server with chunking method (flow over 64MB)") upload_session_id = self.parent_srv.fileuploads.upload(file) - url = "{0}&uploadSessionId={1}".format(url, upload_session_id) + url = f"{url}&uploadSessionId={upload_session_id}" xml_request, content_type = RequestFactory.Flow.publish_req_chunked(flow_item, connections) else: - logger.info("Publishing {0} to server".format(filename)) + logger.info(f"Publishing {filename} to server") if isinstance(file, (str, Path)): with open(file, "rb") as f: @@ -259,7 +260,7 @@ def publish( raise err else: new_flow = FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (ID: {1})".format(filename, new_flow.id)) + logger.info(f"Published {filename} (ID: {new_flow.id})") return new_flow @api(version="3.3") @@ -294,7 +295,7 @@ def delete_dqw(self, item: FlowItem) -> None: @api(version="3.3") def schedule_flow_run( self, schedule_id: str, item: FlowItem - ) -> List["AddResponse"]: # actually should return a task + ) -> list["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, flow=item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowItem]: diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 8acf31692..c512b011b 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -8,7 +8,8 @@ from tableauserverclient.helpers.logging import logger -from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable from tableauserverclient.server.query import QuerySet @@ -19,10 +20,10 @@ class Groups(QuerysetEndpoint[GroupItem]): @property def baseurl(self) -> str: - return "{0}/sites/{1}/groups".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/groups" @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[GroupItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[GroupItem], PaginationItem]: """Gets all groups""" logger.info("Querying all groups on site") url = self.baseurl @@ -50,12 +51,12 @@ def user_pager(): def _get_users_for_group( self, group_item: GroupItem, req_options: Optional["RequestOptions"] = None - ) -> Tuple[List[UserItem], PaginationItem]: - url = "{0}/{1}/users".format(self.baseurl, group_item.id) + ) -> tuple[list[UserItem], PaginationItem]: + url = f"{self.baseurl}/{group_item.id}/users" server_response = self.get_request(url, req_options) user_item = UserItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) - logger.info("Populated users for group (ID: {0})".format(group_item.id)) + logger.info(f"Populated users for group (ID: {group_item.id})") return user_item, pagination_item @api(version="2.0") @@ -64,13 +65,13 @@ def delete(self, group_id: str) -> None: if not group_id: error = "Group ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, group_id) + url = f"{self.baseurl}/{group_id}" self.delete_request(url) - logger.info("Deleted single group (ID: {0})".format(group_id)) + logger.info(f"Deleted single group (ID: {group_id})") @api(version="2.0") def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem, JobItem]: - url = "{0}/{1}".format(self.baseurl, group_item.id) + url = f"{self.baseurl}/{group_item.id}" if not group_item.id: error = "Group item missing ID." @@ -83,7 +84,7 @@ def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem update_req = RequestFactory.Group.update_req(group_item) server_response = self.put_request(url, update_req) - logger.info("Updated group item (ID: {0})".format(group_item.id)) + logger.info(f"Updated group item (ID: {group_item.id})") if as_job: return JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] else: @@ -118,9 +119,9 @@ def remove_user(self, group_item: GroupItem, user_id: str) -> None: if not user_id: error = "User ID undefined." raise ValueError(error) - url = "{0}/{1}/users/{2}".format(self.baseurl, group_item.id, user_id) + url = f"{self.baseurl}/{group_item.id}/users/{user_id}" self.delete_request(url) - logger.info("Removed user (id: {0}) from group (ID: {1})".format(user_id, group_item.id)) + logger.info(f"Removed user (id: {user_id}) from group (ID: {group_item.id})") @api(version="3.21") def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> None: @@ -132,7 +133,7 @@ def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserIte url = f"{self.baseurl}/{group_id}/users/remove" add_req = RequestFactory.Group.remove_users_req(users) _ = self.put_request(url, add_req) - logger.info("Removed users to group (ID: {0})".format(group_item.id)) + logger.info(f"Removed users to group (ID: {group_item.id})") return None @api(version="2.0") @@ -144,15 +145,15 @@ def add_user(self, group_item: GroupItem, user_id: str) -> UserItem: if not user_id: error = "User ID undefined." raise ValueError(error) - url = "{0}/{1}/users".format(self.baseurl, group_item.id) + url = f"{self.baseurl}/{group_item.id}/users" add_req = RequestFactory.Group.add_user_req(user_id) server_response = self.post_request(url, add_req) user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() - logger.info("Added user (id: {0}) to group (ID: {1})".format(user_id, group_item.id)) + logger.info(f"Added user (id: {user_id}) to group (ID: {group_item.id})") return user @api(version="3.21") - def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> List[UserItem]: + def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> list[UserItem]: """Adds multiple users to 1 group""" group_id = group_item.id if hasattr(group_item, "id") else group_item if not isinstance(group_id, str): @@ -162,7 +163,7 @@ def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]] add_req = RequestFactory.Group.add_users_req(users) server_response = self.post_request(url, add_req) users = UserItem.from_response(server_response.content, self.parent_srv.namespace) - logger.info("Added users to group (ID: {0})".format(group_item.id)) + logger.info(f"Added users to group (ID: {group_item.id})") return users def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[GroupItem]: diff --git a/tableauserverclient/server/endpoint/groupsets_endpoint.py b/tableauserverclient/server/endpoint/groupsets_endpoint.py index 06e7cc627..c7f5ed0e5 100644 --- a/tableauserverclient/server/endpoint/groupsets_endpoint.py +++ b/tableauserverclient/server/endpoint/groupsets_endpoint.py @@ -1,4 +1,4 @@ -from typing import List, Literal, Optional, Tuple, TYPE_CHECKING, Union +from typing import Literal, Optional, TYPE_CHECKING, Union from tableauserverclient.helpers.logging import logger from tableauserverclient.models.group_item import GroupItem @@ -27,7 +27,7 @@ def get( self, request_options: Optional[RequestOptions] = None, result_level: Optional[Literal["members", "local"]] = None, - ) -> Tuple[List[GroupSetItem], PaginationItem]: + ) -> tuple[list[GroupSetItem], PaginationItem]: logger.info("Querying all group sets on site") url = self.baseurl if result_level: diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index ae8cf2633..723d3dd38 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -11,24 +11,24 @@ from tableauserverclient.helpers.logging import logger -from typing import List, Optional, Tuple, Union +from typing import Optional, Union class Jobs(QuerysetEndpoint[BackgroundJobItem]): @property def baseurl(self): - return "{0}/sites/{1}/jobs".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/jobs" @overload # type: ignore[override] def get(self: Self, job_id: str, req_options: Optional[RequestOptionsBase] = None) -> JobItem: # type: ignore[override] ... @overload # type: ignore[override] - def get(self: Self, job_id: RequestOptionsBase, req_options: None) -> Tuple[List[BackgroundJobItem], PaginationItem]: # type: ignore[override] + def get(self: Self, job_id: RequestOptionsBase, req_options: None) -> tuple[list[BackgroundJobItem], PaginationItem]: # type: ignore[override] ... @overload # type: ignore[override] - def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> Tuple[List[BackgroundJobItem], PaginationItem]: # type: ignore[override] + def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> tuple[list[BackgroundJobItem], PaginationItem]: # type: ignore[override] ... @api(version="2.6") @@ -53,13 +53,13 @@ def cancel(self, job_id: Union[str, JobItem]): if isinstance(job_id, JobItem): job_id = job_id.id assert isinstance(job_id, str) - url = "{0}/{1}".format(self.baseurl, job_id) + url = f"{self.baseurl}/{job_id}" return self.put_request(url) @api(version="2.6") def get_by_id(self, job_id: str) -> JobItem: logger.info("Query for information about job " + job_id) - url = "{0}/{1}".format(self.baseurl, job_id) + url = f"{self.baseurl}/{job_id}" server_response = self.get_request(url) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job @@ -77,7 +77,7 @@ def wait_for_job(self, job_id: Union[str, JobItem], *, timeout: Optional[float] job = self.get_by_id(job_id) logger.debug(f"\tJob {job_id} progress={job.progress}") - logger.info("Job {} Completed: Finish Code: {} - Notes:{}".format(job_id, job.finish_code, job.notes)) + logger.info(f"Job {job_id} Completed: Finish Code: {job.finish_code} - Notes:{job.notes}") if job.finish_code == JobItem.FinishCode.Success: return job diff --git a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py index 374130509..ede4d38e3 100644 --- a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Tuple, Union +from typing import Optional, Union from tableauserverclient.helpers.logging import logger from tableauserverclient.models.linked_tasks_item import LinkedTaskItem, LinkedTaskJobItem @@ -18,7 +18,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/linked" @api(version="3.15") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[LinkedTaskItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[LinkedTaskItem], PaginationItem]: logger.info("Querying all linked tasks on site") url = self.baseurl server_response = self.get_request(url, req_options) diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index 38c3eebb6..e5dbcbcf8 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -50,11 +50,11 @@ def get_page_info(result): class Metadata(Endpoint): @property def baseurl(self): - return "{0}/api/metadata/graphql".format(self.parent_srv.server_address) + return f"{self.parent_srv.server_address}/api/metadata/graphql" @property def control_baseurl(self): - return "{0}/api/metadata/v1/control".format(self.parent_srv.server_address) + return f"{self.parent_srv.server_address}/api/metadata/v1/control" @api("3.5") def query(self, query, variables=None, abort_on_error=False, parameters=None): diff --git a/tableauserverclient/server/endpoint/metrics_endpoint.py b/tableauserverclient/server/endpoint/metrics_endpoint.py index ab1ec5852..3fea1f5b6 100644 --- a/tableauserverclient/server/endpoint/metrics_endpoint.py +++ b/tableauserverclient/server/endpoint/metrics_endpoint.py @@ -8,7 +8,7 @@ import logging -from typing import List, Optional, TYPE_CHECKING, Tuple +from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from ..request_options import RequestOptions @@ -20,18 +20,18 @@ class Metrics(QuerysetEndpoint[MetricItem]): def __init__(self, parent_srv: "Server") -> None: - super(Metrics, self).__init__(parent_srv) + super().__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "metric") @property def baseurl(self) -> str: - return "{0}/sites/{1}/metrics".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/metrics" # Get all metrics @api(version="3.9") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[MetricItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[MetricItem], PaginationItem]: logger.info("Querying all metrics on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -45,8 +45,8 @@ def get_by_id(self, metric_id: str) -> MetricItem: if not metric_id: error = "Metric ID undefined." raise ValueError(error) - logger.info("Querying single metric (ID: {0})".format(metric_id)) - url = "{0}/{1}".format(self.baseurl, metric_id) + logger.info(f"Querying single metric (ID: {metric_id})") + url = f"{self.baseurl}/{metric_id}" server_response = self.get_request(url) return MetricItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -56,9 +56,9 @@ def delete(self, metric_id: str) -> None: if not metric_id: error = "Metric ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, metric_id) + url = f"{self.baseurl}/{metric_id}" self.delete_request(url) - logger.info("Deleted single metric (ID: {0})".format(metric_id)) + logger.info(f"Deleted single metric (ID: {metric_id})") # Update metric @api(version="3.9") @@ -70,8 +70,8 @@ def update(self, metric_item: MetricItem) -> MetricItem: self._resource_tagger.update_tags(self.baseurl, metric_item) # Update the metric itself - url = "{0}/{1}".format(self.baseurl, metric_item.id) + url = f"{self.baseurl}/{metric_item.id}" update_req = RequestFactory.Metric.update_req(metric_item) server_response = self.put_request(url, update_req) - logger.info("Updated metric item (ID: {0})".format(metric_item.id)) + logger.info(f"Updated metric item (ID: {metric_item.id})") return MetricItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index 4433625f2..10d420ff7 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -6,7 +6,7 @@ from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError -from typing import Callable, TYPE_CHECKING, List, Optional, Union +from typing import Callable, TYPE_CHECKING, Optional, Union from tableauserverclient.helpers.logging import logger @@ -25,7 +25,7 @@ class _PermissionsEndpoint(Endpoint): """ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None: - super(_PermissionsEndpoint, self).__init__(parent_srv) + super().__init__(parent_srv) # owner_baseurl is the baseurl of the parent. The MUST be a lambda # since we don't know the full site URL until we sign in. If @@ -33,18 +33,18 @@ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> No self.owner_baseurl = owner_baseurl def __str__(self): - return "".format(self.owner_baseurl) + return f"" - def update(self, resource: TableauItem, permissions: List[PermissionsRule]) -> List[PermissionsRule]: - url = "{0}/{1}/permissions".format(self.owner_baseurl(), resource.id) + def update(self, resource: TableauItem, permissions: list[PermissionsRule]) -> list[PermissionsRule]: + url = f"{self.owner_baseurl()}/{resource.id}/permissions" update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace) - logger.info("Updated permissions for resource {0}: {1}".format(resource.id, permissions)) + logger.info(f"Updated permissions for resource {resource.id}: {permissions}") return permissions - def delete(self, resource: TableauItem, rules: Union[PermissionsRule, List[PermissionsRule]]): + def delete(self, resource: TableauItem, rules: Union[PermissionsRule, list[PermissionsRule]]): # Delete is the only endpoint that doesn't take a list of rules # so let's fake it to keep it consistent # TODO that means we need error handling around the call @@ -54,7 +54,7 @@ def delete(self, resource: TableauItem, rules: Union[PermissionsRule, List[Permi for rule in rules: for capability, mode in rule.capabilities.items(): "/permissions/groups/group-id/capability-name/capability-mode" - url = "{0}/{1}/permissions/{2}/{3}/{4}/{5}".format( + url = "{}/{}/permissions/{}/{}/{}/{}".format( self.owner_baseurl(), resource.id, rule.grantee.tag_name + "s", @@ -63,13 +63,11 @@ def delete(self, resource: TableauItem, rules: Union[PermissionsRule, List[Permi mode, ) - logger.debug("Removing {0} permission for capability {1}".format(mode, capability)) + logger.debug(f"Removing {mode} permission for capability {capability}") self.delete_request(url) - logger.info( - "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) - ) + logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}") def populate(self, item: TableauItem): if not item.id: @@ -80,12 +78,12 @@ def permission_fetcher(): return self._get_permissions(item) item._set_permissions(permission_fetcher) - logger.info("Populated permissions for item (ID: {0})".format(item.id)) + logger.info(f"Populated permissions for item (ID: {item.id})") def _get_permissions(self, item: TableauItem, req_options: Optional["RequestOptions"] = None): - url = "{0}/{1}/permissions".format(self.owner_baseurl(), item.id) + url = f"{self.owner_baseurl()}/{item.id}/permissions" server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) - logger.info("Permissions for resource {0}: {1}".format(item.id, permissions)) + logger.info(f"Permissions for resource {item.id}: {permissions}") return permissions diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 565817e37..74bb865c7 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -5,9 +5,10 @@ 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 List, Optional, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from tableauserverclient.server.query import QuerySet @@ -20,17 +21,17 @@ class Projects(QuerysetEndpoint[ProjectItem]): def __init__(self, parent_srv: "Server") -> None: - super(Projects, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @property def baseurl(self) -> str: - return "{0}/sites/{1}/projects".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/projects" @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[ProjectItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ProjectItem], PaginationItem]: logger.info("Querying all projects on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -43,9 +44,9 @@ def delete(self, project_id: str) -> None: if not project_id: error = "Project ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, project_id) + url = f"{self.baseurl}/{project_id}" self.delete_request(url) - logger.info("Deleted single project (ID: {0})".format(project_id)) + logger.info(f"Deleted single project (ID: {project_id})") @api(version="2.0") def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem: @@ -54,10 +55,10 @@ def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte raise MissingRequiredFieldError(error) params = {"params": {RequestOptions.Field.PublishSamples: samples}} - url = "{0}/{1}".format(self.baseurl, project_item.id) + url = f"{self.baseurl}/{project_item.id}" update_req = RequestFactory.Project.update_req(project_item) server_response = self.put_request(url, update_req, XML_CONTENT_TYPE, params) - logger.info("Updated project item (ID: {0})".format(project_item.id)) + logger.info(f"Updated project item (ID: {project_item.id})") updated_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_project @@ -66,11 +67,11 @@ def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte params = {"params": {RequestOptions.Field.PublishSamples: samples}} url = self.baseurl if project_item._samples: - url = "{0}?publishSamples={1}".format(self.baseurl, project_item._samples) + url = f"{self.baseurl}?publishSamples={project_item._samples}" create_req = RequestFactory.Project.create_req(project_item) server_response = self.post_request(url, create_req, XML_CONTENT_TYPE, params) new_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Created new project (ID: {0})".format(new_project.id)) + logger.info(f"Created new project (ID: {new_project.id})") return new_project @api(version="2.0") @@ -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/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 1894e3b8a..63c03b3e3 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,6 +1,7 @@ import abc import copy -from typing import Generic, Iterable, Optional, Protocol, Set, TypeVar, Union, TYPE_CHECKING, runtime_checkable +from typing import Generic, Optional, Protocol, TypeVar, Union, TYPE_CHECKING, runtime_checkable +from collections.abc import Iterable import urllib.parse from tableauserverclient.server.endpoint.endpoint import Endpoint, api @@ -24,7 +25,7 @@ class _ResourceTagger(Endpoint): # Add new tags to resource def _add_tags(self, baseurl, resource_id, tag_set): - url = "{0}/{1}/tags".format(baseurl, resource_id) + url = f"{baseurl}/{resource_id}/tags" add_req = RequestFactory.Tag.add_req(tag_set) try: @@ -39,7 +40,7 @@ def _add_tags(self, baseurl, resource_id, tag_set): # Delete a resource's tag by name def _delete_tag(self, baseurl, resource_id, tag_name): encoded_tag_name = urllib.parse.quote(tag_name) - url = "{0}/{1}/tags/{2}".format(baseurl, resource_id, encoded_tag_name) + url = f"{baseurl}/{resource_id}/tags/{encoded_tag_name}" try: self.delete_request(url) @@ -59,7 +60,7 @@ def update_tags(self, baseurl, resource_item): if add_set: resource_item.tags = self._add_tags(baseurl, resource_item.id, add_set) resource_item._initial_tags = copy.copy(resource_item.tags) - logger.info("Updated tags to {0}".format(resource_item.tags)) + logger.info(f"Updated tags to {resource_item.tags}") class Response(Protocol): @@ -68,8 +69,8 @@ class Response(Protocol): @runtime_checkable class Taggable(Protocol): - tags: Set[str] - _initial_tags: Set[str] + tags: set[str] + _initial_tags: set[str] @property def id(self) -> Optional[str]: @@ -95,14 +96,14 @@ def put_request(self, url, request) -> Response: def delete_request(self, url) -> None: pass - def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> set[str]: item_id = getattr(item, "id", item) if not isinstance(item_id, str): raise ValueError("ID not found.") if isinstance(tags, str): - tag_set = set([tags]) + tag_set = {tags} else: tag_set = set(tags) @@ -118,7 +119,7 @@ def delete_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> N raise ValueError("ID not found.") if isinstance(tags, str): - tag_set = set([tags]) + tag_set = {tags} else: tag_set = set(tags) @@ -158,9 +159,9 @@ def baseurl(self): return f"{self.parent_srv.baseurl}/tags" @api(version="3.9") - def batch_add(self, tags: Union[Iterable[str], str], content: content) -> Set[str]: + def batch_add(self, tags: Union[Iterable[str], str], content: content) -> set[str]: if isinstance(tags, str): - tag_set = set([tags]) + tag_set = {tags} else: tag_set = set(tags) @@ -170,9 +171,9 @@ def batch_add(self, tags: Union[Iterable[str], str], content: content) -> Set[st return TagItem.from_response(server_response.content, self.parent_srv.namespace) @api(version="3.9") - def batch_delete(self, tags: Union[Iterable[str], str], content: content) -> Set[str]: + def batch_delete(self, tags: Union[Iterable[str], str], content: content) -> set[str]: if isinstance(tags, str): - tag_set = set([tags]) + tag_set = {tags} else: tag_set = set(tags) diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index cfaee3324..eec4536f9 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -2,7 +2,7 @@ import logging import warnings from collections import namedtuple -from typing import TYPE_CHECKING, Callable, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Callable, Optional, Union from .endpoint import Endpoint, api, parameter_added_in from .exceptions import MissingRequiredFieldError @@ -22,14 +22,14 @@ class Schedules(Endpoint): @property def baseurl(self) -> str: - return "{0}/schedules".format(self.parent_srv.baseurl) + return f"{self.parent_srv.baseurl}/schedules" @property def siteurl(self) -> str: - return "{0}/sites/{1}/schedules".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/schedules" @api(version="2.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[ScheduleItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ScheduleItem], PaginationItem]: logger.info("Querying all schedules") url = self.baseurl server_response = self.get_request(url, req_options) @@ -42,8 +42,8 @@ def get_by_id(self, schedule_id): if not schedule_id: error = "No Schedule ID provided" raise ValueError(error) - logger.info("Querying a single schedule by id ({})".format(schedule_id)) - url = "{0}/{1}".format(self.baseurl, schedule_id) + logger.info(f"Querying a single schedule by id ({schedule_id})") + url = f"{self.baseurl}/{schedule_id}" server_response = self.get_request(url) return ScheduleItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -52,9 +52,9 @@ def delete(self, schedule_id: str) -> None: if not schedule_id: error = "Schedule ID undefined" raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, schedule_id) + url = f"{self.baseurl}/{schedule_id}" self.delete_request(url) - logger.info("Deleted single schedule (ID: {0})".format(schedule_id)) + logger.info(f"Deleted single schedule (ID: {schedule_id})") @api(version="2.3") def update(self, schedule_item: ScheduleItem) -> ScheduleItem: @@ -62,10 +62,10 @@ def update(self, schedule_item: ScheduleItem) -> ScheduleItem: error = "Schedule item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, schedule_item.id) + url = f"{self.baseurl}/{schedule_item.id}" update_req = RequestFactory.Schedule.update_req(schedule_item) server_response = self.put_request(url, update_req) - logger.info("Updated schedule item (ID: {})".format(schedule_item.id)) + logger.info(f"Updated schedule item (ID: {schedule_item.id})") updated_schedule = copy.copy(schedule_item) return updated_schedule._parse_common_tags(server_response.content, self.parent_srv.namespace) @@ -79,7 +79,7 @@ def create(self, schedule_item: ScheduleItem) -> ScheduleItem: create_req = RequestFactory.Schedule.create_req(schedule_item) server_response = self.post_request(url, create_req) new_schedule = ScheduleItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Created new schedule (ID: {})".format(new_schedule.id)) + logger.info(f"Created new schedule (ID: {new_schedule.id})") return new_schedule @api(version="2.8") @@ -91,12 +91,12 @@ def add_to_schedule( datasource: Optional["DatasourceItem"] = None, flow: Optional["FlowItem"] = None, task_type: Optional[str] = None, - ) -> List[AddResponse]: + ) -> list[AddResponse]: # There doesn't seem to be a good reason to allow one item of each type? if workbook and datasource: warnings.warn("Passing in multiple items for add_to_schedule will be deprecated", PendingDeprecationWarning) - items: List[ - Tuple[str, Union[WorkbookItem, FlowItem, DatasourceItem], str, Callable[[Optional[str], str], bytes], str] + items: list[ + tuple[str, Union[WorkbookItem, FlowItem, DatasourceItem], str, Callable[[Optional[str], str], bytes], str] ] = [] if workbook is not None: @@ -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, @@ -133,13 +132,13 @@ def _add_to( item_task_type, ) -> AddResponse: id_ = resource.id - url = "{0}/{1}/{2}s".format(self.siteurl, schedule_id, type_) + url = f"{self.siteurl}/{schedule_id}/{type_}s" add_req = req_factory(id_, task_type=item_task_type) # type: ignore[call-arg, arg-type] response = self.put_request(url, add_req) error, warnings, task_created = ScheduleItem.parse_add_to_schedule_response(response, self.parent_srv.namespace) if task_created: - logger.info("Added {} to {} to schedule {}".format(type_, id_, schedule_id)) + logger.info(f"Added {type_} to {id_} to schedule {schedule_id}") if error is not None or warnings is not None: return AddResponse( diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index 26aaf2910..dc934496a 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 @@ -21,15 +22,49 @@ def serverInfo(self): return self._info def __repr__(self): - return "".format(self.serverInfo) + return f"" @property - def baseurl(self): - return "{0}/serverInfo".format(self.parent_srv.baseurl) + 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 dfec49ae1..55d2a5ad0 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -8,20 +8,49 @@ from tableauserverclient.helpers.logging import logger -from typing import TYPE_CHECKING, List, Optional, Tuple +from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: from ..request_options import RequestOptions 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 "{0}/sites".format(self.parent_srv.baseurl) + return f"{self.parent_srv.baseurl}/sites" # Gets all sites @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[SiteItem], PaginationItem]: + 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) @@ -40,20 +96,45 @@ def get_by_id(self, site_id: str) -> SiteItem: error = "You can only retrieve the site for which you are currently authenticated." raise ValueError(error) - logger.info("Querying single site (ID: {0})".format(site_id)) - url = "{0}/{1}".format(self.baseurl, site_id) + logger.info(f"Querying single site (ID: {site_id})") + url = f"{self.baseurl}/{site_id}" server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] # 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) print("Note: You can only work with the site for which you are currently authenticated") - logger.info("Querying single site (Name: {0})".format(site_name)) - url = "{0}/{1}?key=name".format(self.baseurl, site_name) + logger.info(f"Querying single site (Name: {site_name})") + url = f"{self.baseurl}/{site_name}?key=name" print(self.baseurl, url) server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -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) @@ -68,15 +174,51 @@ def get_by_content_url(self, content_url: str) -> SiteItem: error = "You can only work with the site you are currently authenticated for" raise ValueError(error) - logger.info("Querying single site (Content URL: {0})".format(content_url)) + logger.info(f"Querying single site (Content URL: {content_url})") logger.debug("Querying other sites requires Server Admin permissions") - url = "{0}/{1}?key=contentUrl".format(self.baseurl, content_url) + url = f"{self.baseurl}/{content_url}?key=contentUrl" server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] # 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) @@ -90,30 +232,94 @@ def update(self, site_item: SiteItem) -> SiteItem: error = "You cannot set admin_mode to ContentOnly and also set a user quota" raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, site_item.id) + url = f"{self.baseurl}/{site_item.id}" update_req = RequestFactory.Site.update_req(site_item, self.parent_srv) server_response = self.put_request(url, update_req) - logger.info("Updated site item (ID: {0})".format(site_item.id)) + logger.info(f"Updated site item (ID: {site_item.id})") update_site = copy.copy(site_item) return update_site._parse_common_tags(server_response.content, self.parent_srv.namespace) # 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) - url = "{0}/{1}".format(self.baseurl, site_id) + url = f"{self.baseurl}/{site_id}" if not site_id == self.parent_srv.site_id: error = "You can only delete the site you are currently authenticated for" raise ValueError(error) self.delete_request(url) self.parent_srv._clear_auth() - logger.info("Deleted single site (ID: {0}) and signed out".format(site_id)) + logger.info(f"Deleted single site (ID: {site_id}) and signed out") # 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" @@ -123,33 +329,92 @@ def create(self, site_item: SiteItem) -> SiteItem: create_req = RequestFactory.Site.create_req(site_item, self.parent_srv) server_response = self.post_request(url, create_req) new_site = SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Created new site (ID: {0})".format(new_site.id)) + logger.info(f"Created new site (ID: {new_site.id})") return new_site @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) - url = "{0}/{1}/encrypt-extracts".format(self.baseurl, site_id) + url = f"{self.baseurl}/{site_id}/encrypt-extracts" empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @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) - url = "{0}/{1}/decrypt-extracts".format(self.baseurl, site_id) + url = f"{self.baseurl}/{site_id}/decrypt-extracts" empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @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) - url = "{0}/{1}/reencrypt-extracts".format(self.baseurl, site_id) + url = f"{self.baseurl}/{site_id}/reencrypt-extracts" empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index a9f2e7bf5..c9abc9b06 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -7,7 +7,7 @@ from tableauserverclient.helpers.logging import logger -from typing import List, Optional, TYPE_CHECKING, Tuple +from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from ..request_options import RequestOptions @@ -16,10 +16,10 @@ class Subscriptions(Endpoint): @property def baseurl(self) -> str: - return "{0}/sites/{1}/subscriptions".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/subscriptions" @api(version="2.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[SubscriptionItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[SubscriptionItem], PaginationItem]: logger.info("Querying all subscriptions for the site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -33,8 +33,8 @@ def get_by_id(self, subscription_id: str) -> SubscriptionItem: if not subscription_id: error = "No Subscription ID provided" raise ValueError(error) - logger.info("Querying a single subscription by id ({})".format(subscription_id)) - url = "{}/{}".format(self.baseurl, subscription_id) + logger.info(f"Querying a single subscription by id ({subscription_id})") + url = f"{self.baseurl}/{subscription_id}" server_response = self.get_request(url) return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -43,7 +43,7 @@ def create(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item: error = "No Susbcription provided" raise ValueError(error) - logger.info("Creating a subscription ({})".format(subscription_item)) + logger.info(f"Creating a subscription ({subscription_item})") url = self.baseurl create_req = RequestFactory.Subscription.create_req(subscription_item) server_response = self.post_request(url, create_req) @@ -54,17 +54,17 @@ def delete(self, subscription_id: str) -> None: if not subscription_id: error = "Subscription ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, subscription_id) + url = f"{self.baseurl}/{subscription_id}" self.delete_request(url) - logger.info("Deleted subscription (ID: {0})".format(subscription_id)) + logger.info(f"Deleted subscription (ID: {subscription_id})") @api(version="2.3") def update(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item.id: error = "Subscription item missing ID. Subscription must be retrieved from server first." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, subscription_item.id) + url = f"{self.baseurl}/{subscription_item.id}" update_req = RequestFactory.Subscription.update_req(subscription_item) server_response = self.put_request(url, update_req) - logger.info("Updated subscription item (ID: {0})".format(subscription_item.id)) + logger.info(f"Updated subscription item (ID: {subscription_item.id})") return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index 36ef78c0a..120d3ba9c 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,5 +1,6 @@ import logging -from typing import Iterable, Set, Union +from typing import Union +from collections.abc import Iterable from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint from tableauserverclient.server.endpoint.endpoint import api, Endpoint @@ -15,14 +16,14 @@ class Tables(Endpoint, TaggingMixin[TableItem]): def __init__(self, parent_srv): - super(Tables, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "table") @property def baseurl(self): - return "{0}/sites/{1}/tables".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tables" @api(version="3.5") def get(self, req_options=None): @@ -39,8 +40,8 @@ def get_by_id(self, table_id): if not table_id: error = "table ID undefined." raise ValueError(error) - logger.info("Querying single table (ID: {0})".format(table_id)) - url = "{0}/{1}".format(self.baseurl, table_id) + logger.info(f"Querying single table (ID: {table_id})") + url = f"{self.baseurl}/{table_id}" server_response = self.get_request(url) return TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -49,9 +50,9 @@ def delete(self, table_id): if not table_id: error = "Database ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, table_id) + url = f"{self.baseurl}/{table_id}" self.delete_request(url) - logger.info("Deleted single table (ID: {0})".format(table_id)) + logger.info(f"Deleted single table (ID: {table_id})") @api(version="3.5") def update(self, table_item): @@ -59,10 +60,10 @@ def update(self, table_item): error = "table item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, table_item.id) + url = f"{self.baseurl}/{table_item.id}" update_req = RequestFactory.Table.update_req(table_item) server_response = self.put_request(url, update_req) - logger.info("Updated table item (ID: {0})".format(table_item.id)) + logger.info(f"Updated table item (ID: {table_item.id})") updated_table = TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_table @@ -80,10 +81,10 @@ def column_fetcher(): ) table_item._set_columns(column_fetcher) - logger.info("Populated columns for table (ID: {0}".format(table_item.id)) + logger.info(f"Populated columns for table (ID: {table_item.id}") def _get_columns_for_table(self, table_item, req_options=None): - url = "{0}/{1}/columns".format(self.baseurl, table_item.id) + url = f"{self.baseurl}/{table_item.id}/columns" server_response = self.get_request(url, req_options) columns = ColumnItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -91,12 +92,12 @@ def _get_columns_for_table(self, table_item, req_options=None): @api(version="3.5") def update_column(self, table_item, column_item): - url = "{0}/{1}/columns/{2}".format(self.baseurl, table_item.id, column_item.id) + url = f"{self.baseurl}/{table_item.id}/columns/{column_item.id}" update_req = RequestFactory.Column.update_req(column_item) server_response = self.put_request(url, update_req) column = ColumnItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Updated table item (ID: {0} & column item {1}".format(table_item.id, column_item.id)) + logger.info(f"Updated table item (ID: {table_item.id} & column item {column_item.id}") return column @api(version="3.5") @@ -128,7 +129,7 @@ def delete_dqw(self, item): self._data_quality_warnings.clear(item) @api(version="3.9") - def add_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> set[str]: return super().add_tags(item, tags) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index a727a515f..eb82c43bc 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import Endpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -15,7 +15,7 @@ class Tasks(Endpoint): @property def baseurl(self) -> str: - return "{0}/sites/{1}/tasks".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks" def __normalize_task_type(self, task_type: str) -> str: """ @@ -23,20 +23,20 @@ def __normalize_task_type(self, task_type: str) -> str: It is different than the tag "extractRefresh" used in the request body. """ if task_type == TaskItem.Type.ExtractRefresh: - return "{}es".format(task_type) + return f"{task_type}es" else: return task_type @api(version="2.6") def get( self, req_options: Optional["RequestOptions"] = None, task_type: str = TaskItem.Type.ExtractRefresh - ) -> Tuple[List[TaskItem], PaginationItem]: + ) -> tuple[list[TaskItem], PaginationItem]: if task_type == TaskItem.Type.DataAcceleration: self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") logger.info("Querying all %s tasks for the site", task_type) - url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(task_type)) + url = f"{self.baseurl}/{self.__normalize_task_type(task_type)}" server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -63,7 +63,7 @@ def create(self, extract_item: TaskItem) -> TaskItem: error = "No extract refresh provided" raise ValueError(error) logger.info("Creating an extract refresh %s", extract_item) - url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh)) + url = f"{self.baseurl}/{self.__normalize_task_type(TaskItem.Type.ExtractRefresh)}" create_req = RequestFactory.Task.create_extract_req(extract_item) server_response = self.post_request(url, create_req) return server_response.content @@ -74,7 +74,7 @@ def run(self, task_item: TaskItem) -> bytes: error = "Task item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}/{2}/runNow".format( + url = "{}/{}/{}/runNow".format( self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh), task_item.id, @@ -92,6 +92,6 @@ def delete(self, task_id: str, task_type: str = TaskItem.Type.ExtractRefresh) -> if not task_id: error = "No Task ID provided" raise ValueError(error) - url = "{0}/{1}/{2}".format(self.baseurl, self.__normalize_task_type(task_type), task_id) + url = f"{self.baseurl}/{self.__normalize_task_type(task_type)}/{task_id}" self.delete_request(url) logger.info("Deleted single task (ID: %s)", task_id) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index c4b6418b7..d81907ae9 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,6 +1,6 @@ import copy import logging -from typing import List, Optional, Tuple +from typing import Optional from tableauserverclient.server.query import QuerySet @@ -14,13 +14,75 @@ 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 "{0}/sites/{1}/users".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/users" # Gets all users @api(version="2.0") - def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[UserItem], PaginationItem]: + 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,55 +98,253 @@ 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) - logger.info("Querying single user (ID: {0})".format(user_id)) - url = "{0}/{1}".format(self.baseurl, user_id) + logger.info(f"Querying single user (ID: {user_id})") + url = f"{self.baseurl}/{user_id}" server_response = self.get_request(url) return UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() # 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) - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" update_req = RequestFactory.User.update_req(user_item, password) server_response = self.put_request(url, update_req) - logger.info("Updated user item (ID: {0})".format(user_item.id)) + logger.info(f"Updated user item (ID: {user_item.id})") updated_item = copy.copy(user_item) return updated_item._parse_common_tags(server_response.content, self.parent_srv.namespace) # 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) - url = "{0}/{1}".format(self.baseurl, user_id) + url = f"{self.baseurl}/{user_id}" if map_assets_to is not None: url += f"?mapAssetsTo={map_assets_to}" self.delete_request(url) - logger.info("Removed single user (ID: {0})".format(user_id)) + logger.info(f"Removed single user (ID: {user_id})") # 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("Add user {}".format(user_item.name)) + logger.info(f"Add user {user_item.name}") add_req = RequestFactory.User.add_req(user_item) server_response = self.post_request(url, add_req) logger.info(server_response) new_user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() - logger.info("Added new user (ID: {0})".format(new_user.id)) + logger.info(f"Added new user (ID: {new_user.id})") return new_user # Add new users to site. This does not actually perform a bulk action, it's syntactic sugar @api(version="2.0") - def add_all(self, users: List[UserItem]): + def add_all(self, users: list[UserItem]): created = [] failed = [] for user in users: @@ -98,7 +358,7 @@ def add_all(self, users: List[UserItem]): # helping the user by parsing a file they could have used to add users through the UI # line format: Username [required], password, display name, license, admin, publish @api(version="2.0") - def create_from_file(self, filepath: str) -> Tuple[List[UserItem], List[Tuple[UserItem, ServerResponseError]]]: + def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[UserItem, ServerResponseError]]]: created = [] failed = [] if not filepath.find("csv"): @@ -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) @@ -133,20 +429,71 @@ def wb_pager(): def _get_wbs_for_user( self, user_item: UserItem, req_options: Optional[RequestOptions] = None - ) -> Tuple[List[WorkbookItem], PaginationItem]: - url = "{0}/{1}/workbooks".format(self.baseurl, user_item.id) + ) -> tuple[list[WorkbookItem], PaginationItem]: + url = f"{self.baseurl}/{user_item.id}/workbooks" server_response = self.get_request(url, req_options) - logger.info("Populated workbooks for user (ID: {0})".format(user_item.id)) + logger.info(f"Populated workbooks for user (ID: {user_item.id})") workbook_item = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) 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) @@ -161,10 +508,10 @@ def groups_for_user_pager(): def _get_groups_for_user( self, user_item: UserItem, req_options: Optional[RequestOptions] = None - ) -> Tuple[List[GroupItem], PaginationItem]: - url = "{0}/{1}/groups".format(self.baseurl, user_item.id) + ) -> tuple[list[GroupItem], PaginationItem]: + url = f"{self.baseurl}/{user_item.id}/groups" server_response = self.get_request(url, req_options) - logger.info("Populated groups for user (ID: {0})".format(user_item.id)) + logger.info(f"Populated groups for user (ID: {user_item.id})") group_item = GroupItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) return group_item, pagination_item diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index f2ccf658e..3709fc41d 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -11,7 +11,8 @@ from tableauserverclient.helpers.logging import logger -from typing import Iterable, Iterator, List, Optional, Set, Tuple, TYPE_CHECKING, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable, Iterator if TYPE_CHECKING: from tableauserverclient.server.request_options import ( @@ -25,22 +26,22 @@ class Views(QuerysetEndpoint[ViewItem], TaggingMixin[ViewItem]): def __init__(self, parent_srv): - super(Views, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) # Used because populate_preview_image functionaliy requires workbook endpoint @property def siteurl(self) -> str: - return "{0}/sites/{1}".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}" @property def baseurl(self) -> str: - return "{0}/views".format(self.siteurl) + return f"{self.siteurl}/views" @api(version="2.2") def get( self, req_options: Optional["RequestOptions"] = None, usage: bool = False - ) -> Tuple[List[ViewItem], PaginationItem]: + ) -> tuple[list[ViewItem], PaginationItem]: logger.info("Querying all views on site") url = self.baseurl if usage: @@ -55,8 +56,8 @@ def get_by_id(self, view_id: str, usage: bool = False) -> ViewItem: if not view_id: error = "View item missing ID." raise MissingRequiredFieldError(error) - logger.info("Querying single view (ID: {0})".format(view_id)) - url = "{0}/{1}".format(self.baseurl, view_id) + logger.info(f"Querying single view (ID: {view_id})") + url = f"{self.baseurl}/{view_id}" if usage: url += "?includeUsageStatistics=true" server_response = self.get_request(url) @@ -72,10 +73,10 @@ def image_fetcher(): return self._get_preview_for_view(view_item) view_item._set_preview_image(image_fetcher) - logger.info("Populated preview image for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated preview image for view (ID: {view_item.id})") def _get_preview_for_view(self, view_item: ViewItem) -> bytes: - url = "{0}/workbooks/{1}/views/{2}/previewImage".format(self.siteurl, view_item.workbook_id, view_item.id) + url = f"{self.siteurl}/workbooks/{view_item.workbook_id}/views/{view_item.id}/previewImage" server_response = self.get_request(url) image = server_response.content return image @@ -90,10 +91,10 @@ def image_fetcher(): return self._get_view_image(view_item, req_options) view_item._set_image(image_fetcher) - logger.info("Populated image for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated image for view (ID: {view_item.id})") def _get_view_image(self, view_item: ViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes: - url = "{0}/{1}/image".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}/image" server_response = self.get_request(url, req_options) image = server_response.content return image @@ -108,10 +109,10 @@ def pdf_fetcher(): return self._get_view_pdf(view_item, req_options) view_item._set_pdf(pdf_fetcher) - logger.info("Populated pdf for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated pdf for view (ID: {view_item.id})") def _get_view_pdf(self, view_item: ViewItem, req_options: Optional["PDFRequestOptions"]) -> bytes: - url = "{0}/{1}/pdf".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}/pdf" server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @@ -126,10 +127,10 @@ def csv_fetcher(): return self._get_view_csv(view_item, req_options) view_item._set_csv(csv_fetcher) - logger.info("Populated csv for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated csv for view (ID: {view_item.id})") def _get_view_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterator[bytes]: - url = "{0}/{1}/data".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{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) @@ -144,10 +145,10 @@ def excel_fetcher(): return self._get_view_excel(view_item, req_options) view_item._set_excel(excel_fetcher) - logger.info("Populated excel for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated excel for view (ID: {view_item.id})") def _get_view_excel(self, view_item: ViewItem, req_options: Optional["ExcelRequestOptions"]) -> Iterator[bytes]: - url = "{0}/{1}/crosstab/excel".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}/crosstab/excel" with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: yield from server_response.iter_content(1024) @@ -176,7 +177,7 @@ def update(self, view_item: ViewItem) -> ViewItem: return view_item @api(version="1.0") - def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> set[str]: return super().add_tags(item, tags) @api(version="1.0") diff --git a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py index f71db00cc..944b72502 100644 --- a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py +++ b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py @@ -1,7 +1,8 @@ from functools import partial import json from pathlib import Path -from typing import Iterable, List, Optional, Set, TYPE_CHECKING, Tuple, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable from tableauserverclient.models.connection_item import ConnectionItem from tableauserverclient.models.pagination_item import PaginationItem @@ -28,7 +29,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/virtualConnections" @api(version="3.18") - def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[VirtualConnectionItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[VirtualConnectionItem], PaginationItem]: server_response = self.get_request(self.baseurl, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) virtual_connections = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace) @@ -44,7 +45,7 @@ def _connection_fetcher(): def _get_virtual_database_connections( self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None - ) -> Tuple[List[ConnectionItem], PaginationItem]: + ) -> tuple[list[ConnectionItem], PaginationItem]: server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/connections", req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -83,7 +84,7 @@ def update(self, virtual_connection: VirtualConnectionItem) -> VirtualConnection @api(version="3.23") def get_revisions( self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None - ) -> Tuple[List[RevisionItem], PaginationItem]: + ) -> tuple[list[RevisionItem], PaginationItem]: server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/revisions", req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, virtual_connection) @@ -159,7 +160,7 @@ def delete_permission(self, item, capability_item): @api(version="3.23") def add_tags( self, virtual_connection: Union[VirtualConnectionItem, str], tags: Union[Iterable[str], str] - ) -> Set[str]: + ) -> set[str]: return super().add_tags(virtual_connection, tags) @api(version="3.23") diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index 597f9c425..06643f99d 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.helpers.logging import logger -from typing import List, Optional, TYPE_CHECKING, Tuple +from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from ..server import Server @@ -15,14 +15,14 @@ class Webhooks(Endpoint): def __init__(self, parent_srv: "Server") -> None: - super(Webhooks, self).__init__(parent_srv) + super().__init__(parent_srv) @property def baseurl(self) -> str: - return "{0}/sites/{1}/webhooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/webhooks" @api(version="3.6") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[WebhookItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WebhookItem], PaginationItem]: logger.info("Querying all Webhooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -35,8 +35,8 @@ def get_by_id(self, webhook_id: str) -> WebhookItem: if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) - logger.info("Querying single webhook (ID: {0})".format(webhook_id)) - url = "{0}/{1}".format(self.baseurl, webhook_id) + logger.info(f"Querying single webhook (ID: {webhook_id})") + url = f"{self.baseurl}/{webhook_id}" server_response = self.get_request(url) return WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -45,9 +45,9 @@ def delete(self, webhook_id: str) -> None: if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, webhook_id) + url = f"{self.baseurl}/{webhook_id}" self.delete_request(url) - logger.info("Deleted single webhook (ID: {0})".format(webhook_id)) + logger.info(f"Deleted single webhook (ID: {webhook_id})") @api(version="3.6") def create(self, webhook_item: WebhookItem) -> WebhookItem: @@ -56,7 +56,7 @@ def create(self, webhook_item: WebhookItem) -> WebhookItem: server_response = self.post_request(url, create_req) new_webhook = WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Created new webhook (ID: {0})".format(new_webhook.id)) + logger.info(f"Created new webhook (ID: {new_webhook.id})") return new_webhook @api(version="3.6") @@ -64,7 +64,7 @@ def test(self, webhook_id: str): if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) - url = "{0}/{1}/test".format(self.baseurl, webhook_id) + url = f"{self.baseurl}/{webhook_id}/test" testOutcome = self.get_request(url) - logger.info("Testing webhook (ID: {0} returned {1})".format(webhook_id, testOutcome)) + logger.info(f"Testing webhook (ID: {webhook_id} returned {testOutcome})") return testOutcome diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index da6eda3de..460017d1a 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 @@ -25,15 +26,11 @@ from tableauserverclient.server import RequestFactory from typing import ( - Iterable, - List, Optional, - Sequence, - Set, - Tuple, TYPE_CHECKING, Union, ) +from collections.abc import Iterable, Sequence if TYPE_CHECKING: from tableauserverclient.server import Server @@ -61,18 +58,34 @@ class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin[WorkbookItem]): def __init__(self, parent_srv: "Server") -> None: - super(Workbooks, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) return None @property def baseurl(self) -> str: - return "{0}/sites/{1}/workbooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/workbooks" # Get all workbooks on site @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[WorkbookItem], PaginationItem]: + 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) @@ -83,18 +96,44 @@ 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) - logger.info("Querying single workbook (ID: {0})".format(workbook_id)) - url = "{0}/{1}".format(self.baseurl, workbook_id) + logger.info(f"Querying single workbook (ID: {workbook_id})") + url = f"{self.baseurl}/{workbook_id}" server_response = self.get_request(url) return WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @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 = "{0}/{1}/refresh".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}/refresh" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -107,10 +146,37 @@ def create_extract( workbook_item: WorkbookItem, encrypt: bool = False, includeAll: bool = True, - datasources: Optional[List["DatasourceItem"]] = None, + 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 = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) + url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}" datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) server_response = self.post_request(url, datasource_req) @@ -120,8 +186,31 @@ 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 = "{0}/{1}/deleteExtract".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}/deleteExtract" datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) server_response = self.post_request(url, datasource_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -130,12 +219,24 @@ 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) - url = "{0}/{1}".format(self.baseurl, workbook_id) + url = f"{self.baseurl}/{workbook_id}" self.delete_request(url) - logger.info("Deleted single workbook (ID: {0})".format(workbook_id)) + logger.info(f"Deleted single workbook (ID: {workbook_id})") # Update workbook @api(version="2.0") @@ -145,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) @@ -152,27 +276,47 @@ def update( self.update_tags(workbook_item) # Update the workbook itself - url = "{0}/{1}".format(self.baseurl, workbook_item.id) + url = f"{self.baseurl}/{workbook_item.id}" if include_view_acceleration_status: url += "?includeViewAccelerationStatus=True" update_req = RequestFactory.Workbook.update_req(workbook_item) server_response = self.put_request(url, update_req) - logger.info("Updated workbook item (ID: {0})".format(workbook_item.id)) + logger.info(f"Updated workbook item (ID: {workbook_item.id})") updated_workbook = copy.copy(workbook_item) return updated_workbook._parse_common_tags(server_response.content, self.parent_srv.namespace) # Update workbook_connection @api(version="2.3") def update_connection(self, workbook_item: WorkbookItem, connection_item: ConnectionItem) -> ConnectionItem: - url = "{0}/{1}/connections/{2}".format(self.baseurl, workbook_item.id, connection_item.id) + """ + 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) connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info( - "Updated workbook item (ID: {0} & connection item {1})".format(workbook_item.id, connection_item.id) - ) + logger.info(f"Updated workbook item (ID: {workbook_item.id} & connection item {connection_item.id})") return connection # Download workbook contents with option of passing in filepath @@ -185,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, @@ -195,18 +367,48 @@ 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) - def view_fetcher() -> List[ViewItem]: + def view_fetcher() -> list[ViewItem]: return self._get_views_for_workbook(workbook_item, usage) workbook_item._set_views(view_fetcher) - logger.info("Populated views for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated views for workbook (ID: {workbook_item.id})") - def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> List[ViewItem]: - url = "{0}/{1}/views".format(self.baseurl, workbook_item.id) + def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> list[ViewItem]: + url = f"{self.baseurl}/{workbook_item.id}/views" if usage: url += "?includeUsageStatistics=true" server_response = self.get_request(url) @@ -220,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) @@ -228,12 +460,12 @@ def connection_fetcher(): return self._get_workbook_connections(workbook_item) workbook_item._set_connections(connection_fetcher) - logger.info("Populated connections for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated connections for workbook (ID: {workbook_item.id})") def _get_workbook_connections( self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None - ) -> List[ConnectionItem]: - url = "{0}/{1}/connections".format(self.baseurl, workbook_item.id) + ) -> list[ConnectionItem]: + url = f"{self.baseurl}/{workbook_item.id}/connections" server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -241,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) @@ -249,16 +509,46 @@ def pdf_fetcher() -> bytes: return self._get_wb_pdf(workbook_item, req_options) workbook_item._set_pdf(pdf_fetcher) - logger.info("Populated pdf for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated pdf for workbook (ID: {workbook_item.id})") def _get_wb_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: - url = "{0}/{1}/pdf".format(self.baseurl, workbook_item.id) + url = f"{self.baseurl}/{workbook_item.id}/pdf" server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @api(version="3.8") def populate_powerpoint(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: + """ + 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) @@ -267,10 +557,10 @@ def pptx_fetcher() -> bytes: return self._get_wb_pptx(workbook_item, req_options) workbook_item._set_powerpoint(pptx_fetcher) - logger.info("Populated powerpoint for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated powerpoint for workbook (ID: {workbook_item.id})") def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: - url = "{0}/{1}/powerpoint".format(self.baseurl, workbook_item.id) + url = f"{self.baseurl}/{workbook_item.id}/powerpoint" server_response = self.get_request(url, req_options) pptx = server_response.content return pptx @@ -278,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) @@ -286,24 +596,75 @@ def image_fetcher() -> bytes: return self._get_wb_preview_image(workbook_item) workbook_item._set_preview_image(image_fetcher) - logger.info("Populated preview image for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated preview image for workbook (ID: {workbook_item.id})") def _get_wb_preview_image(self, workbook_item: WorkbookItem) -> bytes: - url = "{0}/{1}/previewImage".format(self.baseurl, workbook_item.id) + url = f"{self.baseurl}/{workbook_item.id}/previewImage" server_response = self.get_request(url) preview_image = server_response.content return preview_image @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") @@ -319,10 +680,87 @@ 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." - raise IOError(error) + raise OSError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] @@ -346,12 +784,12 @@ def publish( elif file_type == "xml": file_extension = "twb" else: - error = "Unsupported file type {}!".format(file_type) + error = f"Unsupported file type {file_type}!" raise ValueError(error) # Generate filename for file object. # This is needed when publishing the workbook in a single request - filename = "{}.{}".format(workbook_item.name, file_extension) + filename = f"{workbook_item.name}.{file_extension}" file_size = get_file_object_size(file) else: @@ -362,30 +800,30 @@ def publish( raise ValueError(error) # Construct the url with the defined mode - url = "{0}?workbookType={1}".format(self.baseurl, file_extension) + url = f"{self.baseurl}?workbookType={file_extension}" if mode == self.parent_srv.PublishMode.Overwrite: - url += "&{0}=true".format(mode.lower()) + url += f"&{mode.lower()}=true" elif mode == self.parent_srv.PublishMode.Append: error = "Workbooks cannot be appended." raise ValueError(error) if as_job: - url += "&{0}=true".format("asJob") + url += "&{}=true".format("asJob") if skip_connection_check: - url += "&{0}=true".format("skipConnectionCheck") + url += "&{}=true".format("skipConnectionCheck") # Determine if chunking is required (64MB is the limit for single upload method) if file_size >= FILESIZE_LIMIT: - logger.info("Publishing {0} to server with chunking method (workbook over 64MB)".format(workbook_item.name)) + logger.info(f"Publishing {workbook_item.name} to server with chunking method (workbook over 64MB)") upload_session_id = self.parent_srv.fileuploads.upload(file) - url = "{0}&uploadSessionId={1}".format(url, upload_session_id) + url = f"{url}&uploadSessionId={upload_session_id}" xml_request, content_type = RequestFactory.Workbook.publish_req_chunked( workbook_item, connections=connections, ) else: - logger.info("Publishing {0} to server".format(filename)) + logger.info(f"Publishing {filename} to server") if isinstance(file, (str, Path)): with open(file, "rb") as f: @@ -403,7 +841,7 @@ def publish( file_contents, connections=connections, ) - logger.debug("Request xml: {0} ".format(redact_xml(xml_request[:1000]))) + logger.debug(f"Request xml: {redact_xml(xml_request[:1000])} ") # Send the publishing request to server try: @@ -415,16 +853,38 @@ def publish( if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (JOB_ID: {1}".format(workbook_item.name, new_job.id)) + logger.info(f"Published {workbook_item.name} (JOB_ID: {new_job.id}") return new_job else: new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (ID: {1})".format(workbook_item.name, new_workbook.id)) + logger.info(f"Published {workbook_item.name} (ID: {new_workbook.id})") return new_workbook # 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) @@ -433,12 +893,12 @@ def revisions_fetcher(): return self._get_workbook_revisions(workbook_item) workbook_item._set_revisions(revisions_fetcher) - logger.info("Populated revisions for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated revisions for workbook (ID: {workbook_item.id})") def _get_workbook_revisions( self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None - ) -> List[RevisionItem]: - url = "{0}/{1}/revisions".format(self.baseurl, workbook_item.id) + ) -> list[RevisionItem]: + url = f"{self.baseurl}/{workbook_item.id}/revisions" server_response = self.get_request(url, req_options) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, workbook_item) return revisions @@ -452,13 +912,47 @@ 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) if revision_number is None: - url = "{0}/{1}/content".format(self.baseurl, workbook_id) + url = f"{self.baseurl}/{workbook_id}/content" else: - url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, workbook_id, revision_number) + url = f"{self.baseurl}/{workbook_id}/revisions/{revision_number}/content" if not include_extract: url += "?includeExtract=False" @@ -480,37 +974,129 @@ def download_revision( f.write(chunk) return_path = os.path.abspath(download_path) - logger.info( - "Downloaded workbook revision {0} to {1} (ID: {2})".format(revision_number, return_path, workbook_id) - ) + logger.info(f"Downloaded workbook revision {revision_number} to {return_path} (ID: {workbook_id})") return return_path @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]) self.delete_request(url) - logger.info("Deleted single workbook revision (ID: {0}) (Revision: {1})".format(workbook_id, revision_number)) + logger.info(f"Deleted single workbook revision (ID: {workbook_id}) (Revision: {revision_number})") # a convenience method @api(version="2.8") def schedule_extract_refresh( self, schedule_id: str, item: WorkbookItem - ) -> List["AddResponse"]: # actually should return a task + ) -> 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]: + 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/filter.py b/tableauserverclient/server/filter.py index b936ceb92..fd90e281f 100644 --- a/tableauserverclient/server/filter.py +++ b/tableauserverclient/server/filter.py @@ -1,7 +1,7 @@ from .request_options import RequestOptions -class Filter(object): +class Filter: def __init__(self, field, operator, value): self.field = field self.operator = operator @@ -16,7 +16,7 @@ def __str__(self): # to [,] # so effectively, remove any spaces between "," and "'" and then remove all "'" value_string = value_string.replace(", '", ",'").replace("'", "") - return "{0}:{1}:{2}".format(self.field, self.operator, value_string) + return f"{self.field}:{self.operator}:{value_string}" @property def value(self): diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index ca9d83872..e6d261b61 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,6 +1,7 @@ import copy from functools import partial -from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable +from typing import Optional, Protocol, TypeVar, Union, runtime_checkable +from collections.abc import Iterable, Iterator from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions @@ -11,14 +12,12 @@ @runtime_checkable class Endpoint(Protocol[T]): - def get(self, req_options: Optional[RequestOptions]) -> Tuple[List[T], PaginationItem]: - ... + def get(self, req_options: Optional[RequestOptions]) -> tuple[list[T], PaginationItem]: ... @runtime_checkable class CallableEndpoint(Protocol[T]): - def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> Tuple[List[T], PaginationItem]: - ... + def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> tuple[list[T], PaginationItem]: ... class Pager(Iterable[T]): @@ -27,7 +26,7 @@ class Pager(Iterable[T]): Supports all `RequestOptions` including starting on any page. Also used by models to load sub-models (users in a group, views in a workbook, etc) by passing a different endpoint. - Will loop over anything that returns (List[ModelItem], PaginationItem). + Will loop over anything that returns (list[ModelItem], PaginationItem). """ def __init__( diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index bbca612e9..801ad4a13 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,8 +1,10 @@ -from collections.abc import Sized +from collections.abc import Iterable, Iterator, Sized from itertools import count -from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TYPE_CHECKING, TypeVar, overload +from typing import Optional, Protocol, TYPE_CHECKING, TypeVar, overload +import sys from tableauserverclient.config import config from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.server.endpoint.exceptions import ServerResponseError from tableauserverclient.server.filter import Filter from tableauserverclient.server.request_options import RequestOptions from tableauserverclient.server.sort import Sort @@ -34,10 +36,36 @@ def to_camel_case(word: str) -> str: class QuerySet(Iterable[T], Sized): + """ + QuerySet is a class that allows easy filtering, sorting, and iterating over + many endpoints in TableauServerClient. It is designed to be used in a similar + way to Django QuerySets, but with a more limited feature set. + + QuerySet is an iterable, and can be used in for loops, list comprehensions, + and other places where iterables are expected. + + QuerySet is also Sized, and can be used in places where the length of the + QuerySet is needed. The length of the QuerySet is the total number of items + available in the QuerySet, not just the number of items that have been + fetched. If the endpoint does not return a total count of items, the length + of the QuerySet will be sys.maxsize. If there is no total count, the + QuerySet will continue to fetch items until there are no more items to + fetch. + + QuerySet is not re-entrant. It is not designed to be used in multiple places + at the same time. If you need to use a QuerySet in multiple places, you + should create a new QuerySet for each place you need to use it, convert it + to a list, or create a deep copy of the QuerySet. + + QuerySets are also indexable, and can be sliced. If you try to access an + index that has not been fetched, the QuerySet will fetch the page that + contains the item you are looking for. + """ + def __init__(self, model: "QuerysetEndpoint[T]", page_size: Optional[int] = None) -> None: self.model = model self.request_options = RequestOptions(pagesize=page_size or config.PAGE_SIZE) - self._result_cache: List[T] = [] + self._result_cache: list[T] = [] self._pagination_item = PaginationItem() def __iter__(self: Self) -> Iterator[T]: @@ -49,19 +77,30 @@ def __iter__(self: Self) -> Iterator[T]: for page in count(1): self.request_options.pagenumber = page self._result_cache = [] - self._fetch_all() + self._pagination_item._page_number = None + try: + self._fetch_all() + except ServerResponseError as e: + if e.code == "400006": + # If the endpoint does not support pagination, it will end + # 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 - # Set result_cache to empty so the fetch will populate - if (page * self.page_size) >= len(self): + # If the length of the QuerySet is unknown, continue fetching until + # the result cache is empty. + if (size := len(self)) == 0: + continue + if (page * self.page_size) >= size: return @overload - def __getitem__(self: Self, k: Slice) -> List[T]: - ... + def __getitem__(self: Self, k: Slice) -> list[T]: ... @overload - def __getitem__(self: Self, k: int) -> T: - ... + def __getitem__(self: Self, k: int) -> T: ... def __getitem__(self, k): page = self.page_number @@ -103,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] @@ -114,11 +154,16 @@ def _fetch_all(self: Self) -> None: """ Retrieve the data and store result and pagination item in cache """ - if not self._result_cache: - self._result_cache, self._pagination_item = self.model.get(self.request_options) + 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 + else: + self._result_cache = response + self._pagination_item = PaginationItem() def __len__(self: Self) -> int: - return self.total_available + return sys.maxsize if self.total_available is None else self.total_available @property def total_available(self: Self) -> int: @@ -128,12 +173,16 @@ def total_available(self: Self) -> int: @property def page_number(self: Self) -> int: self._fetch_all() - return self._pagination_item.page_number + # If the PaginationItem is not returned from the endpoint, use the + # pagenumber from the RequestOptions. + return self._pagination_item.page_number or self.request_options.pagenumber @property def page_size(self: Self) -> int: self._fetch_all() - return self._pagination_item.page_size + # If the PaginationItem is not returned from the endpoint, use the + # pagesize from the RequestOptions. + return self._pagination_item.page_size or self.request_options.pagesize def filter(self: Self, *invalid, page_size: Optional[int] = None, **kwargs) -> Self: if invalid: @@ -160,22 +209,22 @@ def paginate(self: Self, **kwargs) -> Self: return self @staticmethod - def _parse_shorthand_filter(key: str) -> Tuple[str, str]: + def _parse_shorthand_filter(key: str) -> tuple[str, str]: tokens = key.split("__", 1) if len(tokens) == 1: operator = RequestOptions.Operator.Equals else: operator = tokens[1] if operator not in RequestOptions.Operator.__dict__.values(): - raise ValueError("Operator `{}` is not valid.".format(operator)) + raise ValueError(f"Operator `{operator}` is not valid.") field = to_camel_case(tokens[0]) if field not in RequestOptions.Field.__dict__.values(): - raise ValueError("Field name `{}` is not valid.".format(field)) + raise ValueError(f"Field name `{field}` is not valid.") return (field, operator) @staticmethod - def _parse_shorthand_sort(key: str) -> Tuple[str, str]: + def _parse_shorthand_sort(key: str) -> tuple[str, str]: direction = RequestOptions.Direction.Asc if key.startswith("-"): direction = RequestOptions.Direction.Desc diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 96fa14680..f7bd139d7 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,5 +1,6 @@ import xml.etree.ElementTree as ET -from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, TypeVar, TYPE_CHECKING, Union +from typing import Any, Callable, Optional, TypeVar, TYPE_CHECKING, Union +from collections.abc import Iterable from typing_extensions import ParamSpec @@ -15,7 +16,7 @@ # this file could be largely replaced if we were willing to import the huge file from generateDS -def _add_multipart(parts: Dict) -> Tuple[Any, str]: +def _add_multipart(parts: dict) -> tuple[Any, str]: mime_multipart_parts = list() for name, (filename, data, content_type) in parts.items(): multipart_part = RequestField(name=name, data=data, filename=filename) @@ -80,7 +81,7 @@ def _add_credentials_element(parent_element, connection_credentials): credentials_element.attrib["oAuth"] = "true" -class AuthRequest(object): +class AuthRequest: def signin_req(self, auth_item): xml_request = ET.Element("tsRequest") @@ -104,7 +105,7 @@ def switch_req(self, site_content_url): return ET.tostring(xml_request) -class ColumnRequest(object): +class ColumnRequest: def update_req(self, column_item): xml_request = ET.Element("tsRequest") column_element = ET.SubElement(xml_request, "column") @@ -115,7 +116,7 @@ def update_req(self, column_item): return ET.tostring(xml_request) -class DataAlertRequest(object): +class DataAlertRequest: def add_user_to_alert(self, alert_item: "DataAlertItem", user_id: str) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -140,7 +141,7 @@ def update_req(self, alert_item: "DataAlertItem") -> bytes: return ET.tostring(xml_request) -class DatabaseRequest(object): +class DatabaseRequest: def update_req(self, database_item): xml_request = ET.Element("tsRequest") database_element = ET.SubElement(xml_request, "database") @@ -159,7 +160,7 @@ def update_req(self, database_item): return ET.tostring(xml_request) -class DatasourceRequest(object): +class DatasourceRequest: def _generate_xml(self, datasource_item: DatasourceItem, connection_credentials=None, connections=None): xml_request = ET.Element("tsRequest") datasource_element = ET.SubElement(xml_request, "datasource") @@ -244,7 +245,7 @@ def publish_req_chunked(self, datasource_item, connection_credentials=None, conn return _add_multipart(parts) -class DQWRequest(object): +class DQWRequest: def add_req(self, dqw_item): xml_request = ET.Element("tsRequest") dqw_element = ET.SubElement(xml_request, "dataQualityWarning") @@ -274,7 +275,7 @@ def update_req(self, dqw_item): return ET.tostring(xml_request) -class FavoriteRequest(object): +class FavoriteRequest: def add_request(self, id_: Optional[str], target_type: str, label: Optional[str]) -> bytes: """ @@ -329,7 +330,7 @@ def add_workbook_req(self, id_: Optional[str], name: Optional[str]) -> bytes: return self.add_request(id_, Resource.Workbook, name) -class FileuploadRequest(object): +class FileuploadRequest: def chunk_req(self, chunk): parts = { "request_payload": ("", "", "text/xml"), @@ -338,8 +339,8 @@ def chunk_req(self, chunk): return _add_multipart(parts) -class FlowRequest(object): - def _generate_xml(self, flow_item: "FlowItem", connections: Optional[List["ConnectionItem"]] = None) -> bytes: +class FlowRequest: + def _generate_xml(self, flow_item: "FlowItem", connections: Optional[list["ConnectionItem"]] = None) -> bytes: xml_request = ET.Element("tsRequest") flow_element = ET.SubElement(xml_request, "flow") if flow_item.name is not None: @@ -370,8 +371,8 @@ def publish_req( flow_item: "FlowItem", filename: str, file_contents: bytes, - connections: Optional[List["ConnectionItem"]] = None, - ) -> Tuple[Any, str]: + connections: Optional[list["ConnectionItem"]] = None, + ) -> tuple[Any, str]: xml_request = self._generate_xml(flow_item, connections) parts = { @@ -380,14 +381,14 @@ def publish_req( } return _add_multipart(parts) - def publish_req_chunked(self, flow_item, connections=None) -> Tuple[Any, str]: + def publish_req_chunked(self, flow_item, connections=None) -> tuple[Any, str]: xml_request = self._generate_xml(flow_item, connections) parts = {"request_payload": ("", xml_request, "text/xml")} return _add_multipart(parts) -class GroupRequest(object): +class GroupRequest: def add_user_req(self, user_id: str) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -477,7 +478,7 @@ def update_req( return ET.tostring(xml_request) -class PermissionRequest(object): +class PermissionRequest: def add_req(self, rules: Iterable[PermissionsRule]) -> bytes: xml_request = ET.Element("tsRequest") permissions_element = ET.SubElement(xml_request, "permissions") @@ -499,7 +500,7 @@ def _add_all_capabilities(self, capabilities_element, capabilities_map): capability_element.attrib["mode"] = mode -class ProjectRequest(object): +class ProjectRequest: def update_req(self, project_item: "ProjectItem") -> bytes: xml_request = ET.Element("tsRequest") project_element = ET.SubElement(xml_request, "project") @@ -530,7 +531,7 @@ def create_req(self, project_item: "ProjectItem") -> bytes: return ET.tostring(xml_request) -class ScheduleRequest(object): +class ScheduleRequest: def create_req(self, schedule_item): xml_request = ET.Element("tsRequest") schedule_element = ET.SubElement(xml_request, "schedule") @@ -609,7 +610,7 @@ def add_flow_req(self, id_: Optional[str], task_type: str = TaskItem.Type.RunFlo return self._add_to_req(id_, "flow", task_type) -class SiteRequest(object): +class SiteRequest: def update_req(self, site_item: "SiteItem", parent_srv: Optional["Server"] = None): xml_request = ET.Element("tsRequest") site_element = ET.SubElement(xml_request, "site") @@ -848,7 +849,7 @@ def set_versioned_flow_attributes(self, flows_all, flows_edit, flows_schedule, p warnings.warn("In version 3.10 and earlier there is only one option: FlowsEnabled") -class TableRequest(object): +class TableRequest: def update_req(self, table_item): xml_request = ET.Element("tsRequest") table_element = ET.SubElement(xml_request, "table") @@ -871,7 +872,7 @@ def update_req(self, table_item): content_types = Iterable[Union["ColumnItem", "DatabaseItem", "DatasourceItem", "FlowItem", "TableItem", "WorkbookItem"]] -class TagRequest(object): +class TagRequest: def add_req(self, tag_set): xml_request = ET.Element("tsRequest") tags_element = ET.SubElement(xml_request, "tags") @@ -881,7 +882,7 @@ def add_req(self, tag_set): return ET.tostring(xml_request) @_tsrequest_wrapped - def batch_create(self, element: ET.Element, tags: Set[str], content: content_types) -> bytes: + def batch_create(self, element: ET.Element, tags: set[str], content: content_types) -> bytes: tag_batch = ET.SubElement(element, "tagBatch") tags_element = ET.SubElement(tag_batch, "tags") for tag in tags: @@ -897,7 +898,7 @@ def batch_create(self, element: ET.Element, tags: Set[str], content: content_typ return ET.tostring(element) -class UserRequest(object): +class UserRequest: def update_req(self, user_item: UserItem, password: Optional[str]) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -931,7 +932,7 @@ def add_req(self, user_item: UserItem) -> bytes: return ET.tostring(xml_request) -class WorkbookRequest(object): +class WorkbookRequest: def _generate_xml( self, workbook_item, @@ -995,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.") @@ -1075,7 +1076,7 @@ def embedded_extract_req( datasource_element.attrib["id"] = id_ -class Connection(object): +class Connection: @_tsrequest_wrapped def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") -> None: connection_element = ET.SubElement(xml_request, "connection") @@ -1098,7 +1099,7 @@ def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") connection_element.attrib["queryTaggingEnabled"] = str(connection_item.query_tagging).lower() -class TaskRequest(object): +class TaskRequest: @_tsrequest_wrapped def run_req(self, xml_request: ET.Element, task_item: Any) -> None: # Send an empty tsRequest @@ -1137,7 +1138,7 @@ def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") return ET.tostring(xml_request) -class FlowTaskRequest(object): +class FlowTaskRequest: @_tsrequest_wrapped def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") -> bytes: flow_element = ET.SubElement(xml_request, "runFlow") @@ -1171,7 +1172,7 @@ def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") - return ET.tostring(xml_request) -class SubscriptionRequest(object): +class SubscriptionRequest: @_tsrequest_wrapped def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionItem") -> bytes: subscription_element = ET.SubElement(xml_request, "subscription") @@ -1235,13 +1236,13 @@ def update_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt return ET.tostring(xml_request) -class EmptyRequest(object): +class EmptyRequest: @_tsrequest_wrapped def empty_req(self, xml_request: ET.Element) -> None: pass -class WebhookRequest(object): +class WebhookRequest: @_tsrequest_wrapped def create_req(self, xml_request: ET.Element, webhook_item: "WebhookItem") -> bytes: webhook = ET.SubElement(xml_request, "webhook") @@ -1287,7 +1288,7 @@ def update_req(self, xml_request: ET.Element, metric_item: MetricItem) -> bytes: return ET.tostring(xml_request) -class CustomViewRequest(object): +class CustomViewRequest: @_tsrequest_wrapped def update_req(self, xml_request: ET.Element, custom_view_item: CustomViewItem): updating_element = ET.SubElement(xml_request, "customView") @@ -1415,7 +1416,7 @@ def publish(self, xml_request: ET.Element, virtual_connection: VirtualConnection return ET.tostring(xml_request) -class RequestFactory(object): +class RequestFactory: Auth = AuthRequest() Connection = Connection() Column = ColumnRequest() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index ddb45834d..d79ac7f73 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 @@ -9,12 +10,12 @@ from tableauserverclient.helpers.logging import logger -class RequestOptionsBase(object): +class RequestOptionsBase: # This method is used if server api version is below 3.7 (2020.1) def apply_query_params(self, url): try: params = self.get_query_params() - params_list = ["{}={}".format(k, v) for (k, v) in params.items()] + params_list = [f"{k}={v}" for (k, v) in params.items()] logger.debug("Applying options to request: <%s(%s)>", self.__class__.__name__, ",".join(params_list)) @@ -22,15 +23,52 @@ def apply_query_params(self, url): url, existing_params = url.split("?") params_list.append(existing_params) - return "{0}?{1}".format(url, "&".join(params_list)) + return "{}?{}".format(url, "&".join(params_list)) 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,60 +156,53 @@ 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 - - def page_number(self, page_number): - self.pagenumber = page_number - return self +""" +These options can be used by methods that are fetching data exported from a specific content item +""" - 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 to the view for a filter that is a normal column - within the view.""" + """Apply a filter based on a column within the view. + Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'""" self.view_filters.append((name, value)) return self def parameter(self, name: str, value: str) -> Self: - """Apply a filter based on a parameter within the workbook.""" + """Apply a filter based on a parameter within the workbook. + Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'""" self.view_parameters.append((name, value)) return self @@ -181,82 +213,73 @@ def _append_view_filters(self, params) -> None: params[name] = value -class CSVRequestOptions(_FilterOptionsBase): - def __init__(self, maxage=-1): - super(CSVRequestOptions, self).__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(ImageRequestOptions, self).__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" @@ -278,61 +301,16 @@ class Orientation: Landscape = "landscape" def __init__(self, page_type=None, orientation=None, maxage=-1, viz_height=None, viz_width=None): - super(PDFRequestOptions, self).__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 e563a7138..4eeefcaf9 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -58,8 +58,64 @@ default_server_version = "2.4" # first version that dropped the legacy auth endpoint -class Server(object): +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" @@ -130,7 +186,7 @@ def validate_connection_settings(self): raise ValueError("Server connection settings not valid", req_ex) def __repr__(self): - return "".format(self.baseurl, self.server_info.serverInfo) + return f"" def add_http_options(self, options_dict: dict): try: @@ -142,7 +198,7 @@ def add_http_options(self, options_dict: dict): # expected errors on invalid input: # 'set' object has no attribute 'keys', 'list' object has no attribute 'keys' # TypeError: cannot convert dictionary update sequence element #0 to a sequence (input is a tuple) - raise ValueError("Invalid http options given: {}".format(options_dict)) + raise ValueError(f"Invalid http options given: {options_dict}") def clear_http_options(self): self._http_options = dict() @@ -176,15 +232,15 @@ def _determine_highest_version(self): old_version = self.version version = self.server_info.get().rest_api_version except ServerInfoEndpointNotFoundError as e: - logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info(f"Could not get version info from server: {e.__class__}{e}") version = self._get_legacy_version() except EndpointUnavailableError as e: - logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info(f"Could not get version info from server: {e.__class__}{e}") version = self._get_legacy_version() except Exception as e: - logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info(f"Could not get version info from server: {e.__class__}{e}") version = None - logger.info("versions: {}, {}".format(version, old_version)) + logger.info(f"versions: {version}, {old_version}") return version or old_version def use_server_version(self): @@ -201,12 +257,12 @@ def check_at_least_version(self, target: str): def assert_at_least_version(self, comparison: str, reason: str): if not self.check_at_least_version(comparison): - error = "{} is not available in API version {}. Requires {}".format(reason, self.version, comparison) + error = f"{reason} is not available in API version {self.version}. Requires {comparison}" raise EndpointUnavailableError(error) @property def baseurl(self): - return "{0}/api/{1}".format(self._server_address, str(self.version)) + return f"{self._server_address}/api/{str(self.version)}" @property def namespace(self): diff --git a/tableauserverclient/server/sort.py b/tableauserverclient/server/sort.py index 2d6bc030a..839a8c8db 100644 --- a/tableauserverclient/server/sort.py +++ b/tableauserverclient/server/sort.py @@ -1,7 +1,7 @@ -class Sort(object): +class Sort: def __init__(self, field, direction): self.field = field self.direction = direction def __str__(self): - return "{0}:{1}".format(self.field, self.direction) + return f"{self.field}:{self.direction}" diff --git a/test/_utils.py b/test/_utils.py index 8527aaf8c..b4ee93bc3 100644 --- a/test/_utils.py +++ b/test/_utils.py @@ -1,5 +1,6 @@ import os.path import unittest +from xml.etree import ElementTree as ET from contextlib import contextmanager TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -18,6 +19,19 @@ def read_xml_assets(*args): return map(read_xml_asset, args) +def server_response_error_factory(code: str, summary: str, detail: str) -> str: + root = ET.Element("tsResponse") + error = ET.SubElement(root, "error") + error.attrib["code"] = code + + summary_element = ET.SubElement(error, "summary") + summary_element.text = summary + + detail_element = ET.SubElement(error, "detail") + detail_element.text = detail + return ET.tostring(root, encoding="utf-8").decode("utf-8") + + @contextmanager def mocked_time(): mock_time = 0 diff --git a/test/assets/flow_runs_get.xml b/test/assets/flow_runs_get.xml index bdce4cdfb..489e8ac63 100644 --- a/test/assets/flow_runs_get.xml +++ b/test/assets/flow_runs_get.xml @@ -1,5 +1,4 @@ - - \ No newline at end of file + diff --git a/test/assets/server_info_wrong_site.html b/test/assets/server_info_wrong_site.html new file mode 100644 index 000000000..e92daeb2d --- /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 eaf13481e..48100ad88 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 80800c86b..6e863a863 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_dataalert.py b/test/test_dataalert.py index d9e00a9db..6f6f1683c 100644 --- a/test/test_dataalert.py +++ b/test/test_dataalert.py @@ -108,5 +108,5 @@ def test_delete_user_from_alert(self) -> None: alert_id = "5ea59b45-e497-5673-8809-bfe213236f75" user_id = "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" with requests_mock.mock() as m: - m.delete(self.baseurl + "/{0}/users/{1}".format(alert_id, user_id), status_code=204) + m.delete(self.baseurl + f"/{alert_id}/users/{user_id}", status_code=204) self.server.data_alerts.delete_user_from_alert(alert_id, user_id) diff --git a/test/test_datasource.py b/test/test_datasource.py index 624eb93e1..45d9ba9c9 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -75,7 +75,7 @@ def test_get(self) -> None: self.assertEqual("Sample datasource", all_datasources[1].name) self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_datasources[1].project_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_datasources[1].owner_id) - self.assertEqual(set(["world", "indicators", "sample"]), all_datasources[1].tags) + self.assertEqual({"world", "indicators", "sample"}, all_datasources[1].tags) self.assertEqual("https://page.com", all_datasources[1].webpage_url) self.assertTrue(all_datasources[1].encrypt_extracts) self.assertFalse(all_datasources[1].has_extracts) @@ -110,7 +110,7 @@ def test_get_by_id(self) -> None: self.assertEqual("Sample datasource", single_datasource.name) self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_datasource.project_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_datasource.owner_id) - self.assertEqual(set(["world", "indicators", "sample"]), single_datasource.tags) + self.assertEqual({"world", "indicators", "sample"}, single_datasource.tags) self.assertEqual(TSC.DatasourceItem.AskDataEnablement.SiteDefault, single_datasource.ask_data_enablement) def test_update(self) -> None: @@ -488,7 +488,7 @@ def test_download_object(self) -> None: def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.tds" - disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) + disposition = f'name="tableau_workbook"; filename="{filename}"' with requests_mock.mock() as m: m.get( self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", @@ -659,7 +659,7 @@ def test_revisions(self) -> None: response_xml = read_xml_asset(REVISION_XML) with requests_mock.mock() as m: - m.get("{0}/{1}/revisions".format(self.baseurl, datasource.id), text=response_xml) + m.get(f"{self.baseurl}/{datasource.id}/revisions", text=response_xml) self.server.datasources.populate_revisions(datasource) revisions = datasource.revisions @@ -687,7 +687,7 @@ def test_delete_revision(self) -> None: datasource._id = "06b944d2-959d-4604-9305-12323c95e70e" with requests_mock.mock() as m: - m.delete("{0}/{1}/revisions/3".format(self.baseurl, datasource.id)) + m.delete(f"{self.baseurl}/{datasource.id}/revisions/3") self.server.datasources.delete_revision(datasource.id, "3") def test_download_revision(self) -> None: diff --git a/test/test_endpoint.py b/test/test_endpoint.py index 8635af978..ff1ef0f72 100644 --- a/test/test_endpoint.py +++ b/test/test_endpoint.py @@ -54,7 +54,7 @@ def test_get_request_stream(self) -> None: self.assertFalse(response._content_consumed) def test_binary_log_truncated(self): - class FakeResponse(object): + class FakeResponse: headers = {"Content-Type": "application/octet-stream"} content = b"\x1337" * 1000 status_code = 200 diff --git a/test/test_favorites.py b/test/test_favorites.py index 6f0be3b3c..87332d70f 100644 --- a/test/test_favorites.py +++ b/test/test_favorites.py @@ -28,7 +28,7 @@ def setUp(self): def test_get(self) -> None: response_xml = read_xml_asset(GET_FAVORITES_XML) with requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) + m.get(f"{self.baseurl}/{self.user.id}", text=response_xml) self.server.favorites.get(self.user) self.assertIsNotNone(self.user._favorites) self.assertEqual(len(self.user.favorites["workbooks"]), 1) @@ -54,7 +54,7 @@ def test_add_favorite_workbook(self) -> None: workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" workbook.name = "Superstore" with requests_mock.mock() as m: - m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) + m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) self.server.favorites.add_favorite_workbook(self.user, workbook) def test_add_favorite_view(self) -> None: @@ -63,7 +63,7 @@ def test_add_favorite_view(self) -> None: view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" view._name = "ENDANGERED SAFARI" with requests_mock.mock() as m: - m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) + m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) self.server.favorites.add_favorite_view(self.user, view) def test_add_favorite_datasource(self) -> None: @@ -72,7 +72,7 @@ def test_add_favorite_datasource(self) -> None: datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" datasource.name = "SampleDS" with requests_mock.mock() as m: - m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) + m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) self.server.favorites.add_favorite_datasource(self.user, datasource) def test_add_favorite_project(self) -> None: @@ -82,7 +82,7 @@ def test_add_favorite_project(self) -> None: project = TSC.ProjectItem("Tableau") project._id = "1d0304cd-3796-429f-b815-7258370b9b74" with requests_mock.mock() as m: - m.put("{0}/{1}".format(baseurl, self.user.id), text=response_xml) + m.put(f"{baseurl}/{self.user.id}", text=response_xml) self.server.favorites.add_favorite_project(self.user, project) def test_delete_favorite_workbook(self) -> None: @@ -90,7 +90,7 @@ def test_delete_favorite_workbook(self) -> None: workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" workbook.name = "Superstore" with requests_mock.mock() as m: - m.delete("{0}/{1}/workbooks/{2}".format(self.baseurl, self.user.id, workbook.id)) + m.delete(f"{self.baseurl}/{self.user.id}/workbooks/{workbook.id}") self.server.favorites.delete_favorite_workbook(self.user, workbook) def test_delete_favorite_view(self) -> None: @@ -98,7 +98,7 @@ def test_delete_favorite_view(self) -> None: view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" view._name = "ENDANGERED SAFARI" with requests_mock.mock() as m: - m.delete("{0}/{1}/views/{2}".format(self.baseurl, self.user.id, view.id)) + m.delete(f"{self.baseurl}/{self.user.id}/views/{view.id}") self.server.favorites.delete_favorite_view(self.user, view) def test_delete_favorite_datasource(self) -> None: @@ -106,7 +106,7 @@ def test_delete_favorite_datasource(self) -> None: datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" datasource.name = "SampleDS" with requests_mock.mock() as m: - m.delete("{0}/{1}/datasources/{2}".format(self.baseurl, self.user.id, datasource.id)) + m.delete(f"{self.baseurl}/{self.user.id}/datasources/{datasource.id}") self.server.favorites.delete_favorite_datasource(self.user, datasource) def test_delete_favorite_project(self) -> None: @@ -115,5 +115,5 @@ def test_delete_favorite_project(self) -> None: project = TSC.ProjectItem("Tableau") project._id = "1d0304cd-3796-429f-b815-7258370b9b74" with requests_mock.mock() as m: - m.delete("{0}/{1}/projects/{2}".format(baseurl, self.user.id, project.id)) + m.delete(f"{baseurl}/{self.user.id}/projects/{project.id}") self.server.favorites.delete_favorite_project(self.user, project) diff --git a/test/test_filesys_helpers.py b/test/test_filesys_helpers.py index 4c8fb0f9f..0f3234d5d 100644 --- a/test/test_filesys_helpers.py +++ b/test/test_filesys_helpers.py @@ -37,7 +37,7 @@ def test_get_file_type_identifies_a_zip_file(self): with BytesIO() as file_object: with ZipFile(file_object, "w") as zf: with BytesIO() as stream: - stream.write("This is a zip file".encode()) + stream.write(b"This is a zip file") zf.writestr("dummy_file", stream.getbuffer()) file_object.seek(0) file_type = get_file_type(file_object) diff --git a/test/test_fileuploads.py b/test/test_fileuploads.py index 50a5ef48b..9567bc3ad 100644 --- a/test/test_fileuploads.py +++ b/test/test_fileuploads.py @@ -33,7 +33,7 @@ def setUp(self): self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = "{}/sites/{}/fileUploads".format(self.server.baseurl, self.server.site_id) + self.baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/fileUploads" def test_read_chunks_file_path(self): file_path = asset("SampleWB.twbx") @@ -57,7 +57,7 @@ def test_upload_chunks_file_path(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) + m.put(f"{self.baseurl}/{upload_id}", text=append_response_xml) actual = self.server.fileuploads.upload(file_path) self.assertEqual(upload_id, actual) @@ -72,7 +72,7 @@ def test_upload_chunks_file_object(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) + m.put(f"{self.baseurl}/{upload_id}", text=append_response_xml) actual = self.server.fileuploads.upload(file_content) self.assertEqual(upload_id, actual) diff --git a/test/test_flowruns.py b/test/test_flowruns.py index 864c0d3cd..8af2540dc 100644 --- a/test/test_flowruns.py +++ b/test/test_flowruns.py @@ -1,3 +1,4 @@ +import sys import unittest import requests_mock @@ -5,7 +6,7 @@ import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException -from ._utils import read_xml_asset, mocked_time +from ._utils import read_xml_asset, mocked_time, server_response_error_factory GET_XML = "flow_runs_get.xml" GET_BY_ID_XML = "flow_runs_get_by_id.xml" @@ -28,9 +29,8 @@ def test_get(self) -> None: response_xml = read_xml_asset(GET_XML) with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) - all_flow_runs, pagination_item = self.server.flow_runs.get() + all_flow_runs = self.server.flow_runs.get() - self.assertEqual(2, pagination_item.total_available) self.assertEqual("cc2e652d-4a9b-4476-8c93-b238c45db968", all_flow_runs[0].id) self.assertEqual("2021-02-11T01:42:55Z", format_datetime(all_flow_runs[0].started_at)) self.assertEqual("2021-02-11T01:57:38Z", format_datetime(all_flow_runs[0].completed_at)) @@ -75,7 +75,7 @@ def test_wait_for_job_finished(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) flow_run_id = "cc2e652d-4a9b-4476-8c93-b238c45db968" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) + m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) flow_run = self.server.flow_runs.wait_for_job(flow_run_id) self.assertEqual(flow_run_id, flow_run.id) @@ -86,7 +86,7 @@ def test_wait_for_job_failed(self) -> None: response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) flow_run_id = "c2b35d5a-e130-471a-aec8-7bc5435fe0e7" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) + m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) with self.assertRaises(FlowRunFailedException): self.server.flow_runs.wait_for_job(flow_run_id) @@ -95,6 +95,17 @@ def test_wait_for_job_timeout(self) -> None: response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) flow_run_id = "71afc22c-9c06-40be-8d0f-4c4166d29e6c" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) + m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) with self.assertRaises(TimeoutError): self.server.flow_runs.wait_for_job(flow_run_id, timeout=30) + + def test_queryset(self) -> None: + response_xml = read_xml_asset(GET_XML) + error_response = server_response_error_factory( + "400006", "Bad Request", "0xB4EAB088 : The start index '9900' is greater than or equal to the total count.)" + ) + with requests_mock.mock() as m: + m.get(f"{self.baseurl}?pageNumber=1", text=response_xml) + m.get(f"{self.baseurl}?pageNumber=2", text=error_response) + queryset = self.server.flow_runs.all() + assert len(queryset) == sys.maxsize diff --git a/test/test_flowtask.py b/test/test_flowtask.py index 034066e64..2d9f7c7bd 100644 --- a/test/test_flowtask.py +++ b/test/test_flowtask.py @@ -40,7 +40,7 @@ def test_create_flow_task(self): with open(GET_XML_CREATE_FLOW_TASK_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post("{}".format(self.baseurl), text=response_xml) + m.post(f"{self.baseurl}", text=response_xml) create_response_content = self.server.flow_tasks.create(task).decode("utf-8") self.assertTrue("schedule_id" in create_response_content) diff --git a/test/test_group.py b/test/test_group.py index fc9c75a6d..41b5992be 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -1,4 +1,3 @@ -# encoding=utf-8 from pathlib import Path import unittest import os diff --git a/test/test_job.py b/test/test_job.py index d86397086..20b238764 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -51,7 +51,7 @@ def test_get_by_id(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" with requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) + m.get(f"{self.baseurl}/{job_id}", text=response_xml) job = self.server.jobs.get_by_id(job_id) updated_at = datetime(2020, 5, 13, 20, 25, 18, tzinfo=utc) @@ -81,7 +81,7 @@ def test_wait_for_job_finished(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) + m.get(f"{self.baseurl}/{job_id}", text=response_xml) job = self.server.jobs.wait_for_job(job_id) self.assertEqual(job_id, job.id) @@ -92,7 +92,7 @@ def test_wait_for_job_failed(self) -> None: response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) + m.get(f"{self.baseurl}/{job_id}", text=response_xml) with self.assertRaises(JobFailedException): self.server.jobs.wait_for_job(job_id) @@ -101,7 +101,7 @@ def test_wait_for_job_timeout(self) -> None: response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) + m.get(f"{self.baseurl}/{job_id}", text=response_xml) with self.assertRaises(TimeoutError): self.server.jobs.wait_for_job(job_id, timeout=30) diff --git a/test/test_pager.py b/test/test_pager.py index c30352809..1836095bb 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_project.py b/test/test_project.py index e05785f86..430db84b2 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -241,9 +241,9 @@ def test_delete_permission(self) -> None: rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) - endpoint = "{}/permissions/groups/{}".format(single_project._id, single_group._id) - m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/Write/Allow".format(self.baseurl, endpoint), status_code=204) + endpoint = f"{single_project._id}/permissions/groups/{single_group._id}" + m.delete(f"{self.baseurl}/{endpoint}/Read/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/Write/Allow", status_code=204) self.server.projects.delete_permission(item=single_project, rules=rules) def test_delete_workbook_default_permission(self) -> None: @@ -287,19 +287,19 @@ def test_delete_workbook_default_permission(self) -> None: rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) - endpoint = "{}/default-permissions/workbooks/groups/{}".format(single_project._id, single_group._id) - m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ExportImage/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ExportData/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ViewComments/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/AddComment/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/Filter/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ViewUnderlyingData/Deny".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ShareView/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/WebAuthoring/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/Write/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ExportXml/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ChangeHierarchy/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/Delete/Deny".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ChangePermissions/Allow".format(self.baseurl, endpoint), status_code=204) + endpoint = f"{single_project._id}/default-permissions/workbooks/groups/{single_group._id}" + m.delete(f"{self.baseurl}/{endpoint}/Read/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ExportImage/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ExportData/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ViewComments/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/AddComment/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/Filter/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ViewUnderlyingData/Deny", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ShareView/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/WebAuthoring/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/Write/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ExportXml/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ChangeHierarchy/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/Delete/Deny", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ChangePermissions/Allow", status_code=204) self.server.projects.delete_workbook_default_permissions(item=single_project, rule=rules) diff --git a/test/test_regression_tests.py b/test/test_regression_tests.py index 772704f69..62e301591 100644 --- a/test/test_regression_tests.py +++ b/test/test_regression_tests.py @@ -1,9 +1,5 @@ import unittest - -try: - from unittest import mock -except ImportError: - import mock # type: ignore[no-redef] +from unittest import mock import tableauserverclient.server.request_factory as factory from tableauserverclient.helpers.strings import redact_xml diff --git a/test/test_request_option.py b/test/test_request_option.py index e48f8510a..7405189a3 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -31,7 +31,7 @@ def setUp(self) -> None: self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = "{0}/{1}".format(self.server.sites.baseurl, self.server._site_id) + self.baseurl = f"{self.server.sites.baseurl}/{self.server._site_id}" def test_pagination(self) -> None: with open(PAGINATION_XML, "rb") as f: @@ -112,9 +112,9 @@ def test_filter_tags_in(self) -> None: matching_workbooks, pagination_item = self.server.workbooks.get(req_option) self.assertEqual(3, pagination_item.total_available) - self.assertEqual(set(["weather"]), matching_workbooks[0].tags) - self.assertEqual(set(["safari"]), matching_workbooks[1].tags) - self.assertEqual(set(["sample"]), matching_workbooks[2].tags) + self.assertEqual({"weather"}, matching_workbooks[0].tags) + self.assertEqual({"safari"}, matching_workbooks[1].tags) + self.assertEqual({"sample"}, matching_workbooks[2].tags) # check if filtered projects with spaces & special characters # get correctly returned @@ -148,9 +148,9 @@ def test_filter_tags_in_shorthand(self) -> None: matching_workbooks = self.server.workbooks.filter(tags__in=["sample", "safari", "weather"]) self.assertEqual(3, matching_workbooks.total_available) - self.assertEqual(set(["weather"]), matching_workbooks[0].tags) - self.assertEqual(set(["safari"]), matching_workbooks[1].tags) - self.assertEqual(set(["sample"]), matching_workbooks[2].tags) + self.assertEqual({"weather"}, matching_workbooks[0].tags) + self.assertEqual({"safari"}, matching_workbooks[1].tags) + self.assertEqual({"sample"}, matching_workbooks[2].tags) def test_invalid_shorthand_option(self) -> None: with self.assertRaises(ValueError): @@ -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_schedule.py b/test/test_schedule.py index 0377295d7..b072522a4 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -106,7 +106,7 @@ def test_get_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -120,7 +120,7 @@ def test_get_hourly_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -135,7 +135,7 @@ def test_get_daily_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -150,7 +150,7 @@ def test_get_monthly_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -165,7 +165,7 @@ def test_get_monthly_by_id_2(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "8c5caf33-6223-4724-83c3-ccdc1e730a07" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -347,7 +347,7 @@ def test_update_after_get(self) -> None: def test_add_workbook(self) -> None: self.server.version = "2.8" - baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: workbook_response = f.read().decode("utf-8") @@ -362,7 +362,7 @@ def test_add_workbook(self) -> None: def test_add_workbook_with_warnings(self) -> None: self.server.version = "2.8" - baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: workbook_response = f.read().decode("utf-8") @@ -378,7 +378,7 @@ def test_add_workbook_with_warnings(self) -> None: def test_add_datasource(self) -> None: self.server.version = "2.8" - baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" with open(DATASOURCE_GET_BY_ID_XML, "rb") as f: datasource_response = f.read().decode("utf-8") @@ -393,7 +393,7 @@ def test_add_datasource(self) -> None: def test_add_flow(self) -> None: self.server.version = "3.3" - baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" with open(FLOW_GET_BY_ID_XML, "rb") as f: flow_response = f.read().decode("utf-8") diff --git a/test/test_server_info.py b/test/test_server_info.py index 1cf190ecd..fa1472c9a 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() diff --git a/test/test_site_model.py b/test/test_site_model.py index f62eb66f0..60ad9c5e5 100644 --- a/test/test_site_model.py +++ b/test/test_site_model.py @@ -1,5 +1,3 @@ -# coding=utf-8 - import unittest import tableauserverclient as TSC diff --git a/test/test_tagging.py b/test/test_tagging.py index 0184af415..23dffebfb 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -1,6 +1,6 @@ from contextlib import ExitStack import re -from typing import Iterable +from collections.abc import Iterable import uuid from xml.etree import ElementTree as ET @@ -172,7 +172,7 @@ def test_update_tags(get_server, endpoint_type, item, tags) -> None: if isinstance(item, str): stack.enter_context(pytest.raises((ValueError, NotImplementedError))) elif hasattr(item, "_initial_tags"): - initial_tags = set(["x", "y", "z"]) + initial_tags = {"x", "y", "z"} item._initial_tags = initial_tags add_tags_xml = add_tag_xml_response_factory(tags - initial_tags) delete_tags_xml = add_tag_xml_response_factory(initial_tags - tags) diff --git a/test/test_task.py b/test/test_task.py index 53da7c160..2d724b879 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -119,7 +119,7 @@ def test_get_materializeviews_tasks(self): with open(GET_XML_DATAACCELERATION_TASK, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get("{}/{}".format(self.server.tasks.baseurl, TaskItem.Type.DataAcceleration), text=response_xml) + m.get(f"{self.server.tasks.baseurl}/{TaskItem.Type.DataAcceleration}", text=response_xml) all_tasks, pagination_item = self.server.tasks.get(task_type=TaskItem.Type.DataAcceleration) task = all_tasks[0] @@ -145,7 +145,7 @@ def test_get_by_id(self): response_xml = f.read().decode("utf-8") task_id = "f84901ac-72ad-4f9b-a87e-7a3500402ad6" with requests_mock.mock() as m: - m.get("{}/{}".format(self.baseurl, task_id), text=response_xml) + m.get(f"{self.baseurl}/{task_id}", text=response_xml) task = self.server.tasks.get_by_id(task_id) self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) @@ -159,7 +159,7 @@ def test_run_now(self): with open(GET_XML_RUN_NOW_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post("{}/{}/runNow".format(self.baseurl, task_id), text=response_xml) + m.post(f"{self.baseurl}/{task_id}/runNow", text=response_xml) job_response_content = self.server.tasks.run(task).decode("utf-8") self.assertTrue("7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6" in job_response_content) @@ -181,7 +181,7 @@ def test_create_extract_task(self): with open(GET_XML_CREATE_TASK_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post("{}".format(self.baseurl), text=response_xml) + m.post(f"{self.baseurl}", text=response_xml) create_response_content = self.server.tasks.create(task).decode("utf-8") self.assertTrue("task_id" in create_response_content) diff --git a/test/test_user.py b/test/test_user.py index 1f5eba57f..a46624845 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,8 +1,5 @@ -import io import os import unittest -from typing import List -from unittest.mock import MagicMock import requests_mock @@ -163,7 +160,7 @@ def test_populate_workbooks(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", workbook_list[0].project_id) self.assertEqual("default", workbook_list[0].project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", workbook_list[0].owner_id) - self.assertEqual(set(["Safari", "Sample"]), workbook_list[0].tags) + self.assertEqual({"Safari", "Sample"}, workbook_list[0].tags) def test_populate_workbooks_missing_id(self) -> None: single_user = TSC.UserItem("test", "Interactor") @@ -176,7 +173,7 @@ def test_populate_favorites(self) -> None: with open(GET_FAVORITES_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get("{0}/{1}".format(baseurl, single_user.id), text=response_xml) + m.get(f"{baseurl}/{single_user.id}", text=response_xml) self.server.users.populate_favorites(single_user) self.assertIsNotNone(single_user._favorites) self.assertEqual(len(single_user.favorites["workbooks"]), 1) diff --git a/test/test_user_model.py b/test/test_user_model.py index d0997b9ff..a8a2c51cb 100644 --- a/test/test_user_model.py +++ b/test/test_user_model.py @@ -1,7 +1,6 @@ import logging import unittest from unittest.mock import * -from typing import List import io import pytest @@ -107,7 +106,7 @@ def test_validate_user_detail_standard(self): TSC.UserItem.CSVImport.create_user_from_line(test_line) # for file handling - def _mock_file_content(self, content: List[str]) -> io.TextIOWrapper: + def _mock_file_content(self, content: list[str]) -> io.TextIOWrapper: # the empty string represents EOF # the tests run through the file twice, first to validate then to fetch mock = MagicMock(io.TextIOWrapper) @@ -119,10 +118,10 @@ def _mock_file_content(self, content: List[str]) -> io.TextIOWrapper: def test_validate_import_file(self): test_data = self._mock_file_content(UserDataTest.valid_import_content) valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) - assert valid == 2, "Expected two lines to be parsed, got {}".format(valid) - assert invalid == [], "Expected no failures, got {}".format(invalid) + assert valid == 2, f"Expected two lines to be parsed, got {valid}" + assert invalid == [], f"Expected no failures, got {invalid}" def test_validate_usernames_file(self): test_data = self._mock_file_content(UserDataTest.usernames) valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) - assert valid == 5, "Exactly 5 of the lines were valid, counted {}".format(valid + invalid) + assert valid == 5, f"Exactly 5 of the lines were valid, counted {valid + invalid}" diff --git a/test/test_view.py b/test/test_view.py index 1c667a4c3..a89a6d235 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -49,7 +49,7 @@ def test_get(self) -> None: self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", all_views[0].workbook_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[0].owner_id) self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", all_views[0].project_id) - self.assertEqual(set(["tag1", "tag2"]), all_views[0].tags) + self.assertEqual({"tag1", "tag2"}, all_views[0].tags) self.assertIsNone(all_views[0].created_at) self.assertIsNone(all_views[0].updated_at) self.assertIsNone(all_views[0].sheet_type) @@ -77,7 +77,7 @@ def test_get_by_id(self) -> None: self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", view.workbook_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", view.owner_id) self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", view.project_id) - self.assertEqual(set(["tag1", "tag2"]), view.tags) + self.assertEqual({"tag1", "tag2"}, view.tags) self.assertEqual("2002-05-30T09:00:00Z", format_datetime(view.created_at)) self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at)) self.assertEqual("story", view.sheet_type) @@ -95,7 +95,7 @@ def test_get_by_id_usage(self) -> None: self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", view.workbook_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", view.owner_id) self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", view.project_id) - self.assertEqual(set(["tag1", "tag2"]), view.tags) + self.assertEqual({"tag1", "tag2"}, view.tags) self.assertEqual("2002-05-30T09:00:00Z", format_datetime(view.created_at)) self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at)) self.assertEqual("story", view.sheet_type) diff --git a/test/test_view_acceleration.py b/test/test_view_acceleration.py index 6f94f0c10..766831b0a 100644 --- a/test/test_view_acceleration.py +++ b/test/test_view_acceleration.py @@ -42,7 +42,7 @@ def test_get_by_id(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_workbook.project_id) self.assertEqual("default", single_workbook.project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id) - self.assertEqual(set(["Safari", "Sample"]), single_workbook.tags) + self.assertEqual({"Safari", "Sample"}, single_workbook.tags) self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_workbook.views[0].id) self.assertEqual("ENDANGERED SAFARI", single_workbook.views[0].name) self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) diff --git a/test/test_workbook.py b/test/test_workbook.py index 950118dc0..1a6b3192f 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -83,7 +83,7 @@ def test_get(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_workbooks[1].project_id) self.assertEqual("default", all_workbooks[1].project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_workbooks[1].owner_id) - self.assertEqual(set(["Safari", "Sample"]), all_workbooks[1].tags) + self.assertEqual({"Safari", "Sample"}, all_workbooks[1].tags) def test_get_ignore_invalid_date(self) -> None: with open(GET_INVALID_DATE_XML, "rb") as f: @@ -127,7 +127,7 @@ def test_get_by_id(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_workbook.project_id) self.assertEqual("default", single_workbook.project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id) - self.assertEqual(set(["Safari", "Sample"]), single_workbook.tags) + self.assertEqual({"Safari", "Sample"}, single_workbook.tags) self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_workbook.views[0].id) self.assertEqual("ENDANGERED SAFARI", single_workbook.views[0].name) self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) @@ -152,7 +152,7 @@ def test_get_by_id_personal(self) -> None: self.assertTrue(single_workbook.project_id) self.assertIsNone(single_workbook.project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id) - self.assertEqual(set(["Safari", "Sample"]), single_workbook.tags) + self.assertEqual({"Safari", "Sample"}, single_workbook.tags) self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_workbook.views[0].id) self.assertEqual("ENDANGERED SAFARI", single_workbook.views[0].name) self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) @@ -277,7 +277,7 @@ def test_download_object(self) -> None: def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.twbx" - disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) + disposition = f'name="tableau_workbook"; filename="{filename}"' with requests_mock.mock() as m: m.get( self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", @@ -817,7 +817,7 @@ def test_revisions(self) -> None: with open(REVISION_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get("{0}/{1}/revisions".format(self.baseurl, workbook.id), text=response_xml) + m.get(f"{self.baseurl}/{workbook.id}/revisions", text=response_xml) self.server.workbooks.populate_revisions(workbook) revisions = workbook.revisions @@ -846,7 +846,7 @@ def test_delete_revision(self) -> None: workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" with requests_mock.mock() as m: - m.delete("{0}/{1}/revisions/3".format(self.baseurl, workbook.id)) + m.delete(f"{self.baseurl}/{workbook.id}/revisions/3") self.server.workbooks.delete_revision(workbook.id, "3") def test_download_revision(self) -> None: diff --git a/versioneer.py b/versioneer.py index 86c240e13..cce899f58 100644 --- a/versioneer.py +++ b/versioneer.py @@ -276,7 +276,6 @@ """ -from __future__ import print_function try: import configparser @@ -328,7 +327,7 @@ def get_root(): me_dir = os.path.normcase(os.path.splitext(me)[0]) vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0]) if me_dir != vsr_dir: - print("Warning: build in %s is using versioneer.py from %s" % (os.path.dirname(me), versioneer_py)) + print(f"Warning: build in {os.path.dirname(me)} is using versioneer.py from {versioneer_py}") except NameError: pass return root @@ -342,7 +341,7 @@ def get_config_from_root(root): # the top of versioneer.py for instructions on writing your setup.cfg . setup_cfg = os.path.join(root, "setup.cfg") parser = configparser.SafeConfigParser() - with open(setup_cfg, "r") as f: + with open(setup_cfg) as f: parser.readfp(f) VCS = parser.get("versioneer", "VCS") # mandatory @@ -398,7 +397,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= [c] + args, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=(subprocess.PIPE if hide_stderr else None) ) break - except EnvironmentError: + except OSError: e = sys.exc_info()[1] if e.errno == errno.ENOENT: continue @@ -408,7 +407,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= return None, None else: if verbose: - print("unable to find command, tried %s" % (commands,)) + print(f"unable to find command, tried {commands}" return None, None stdout = p.communicate()[0].strip() if sys.version_info[0] >= 3: @@ -423,7 +422,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= LONG_VERSION_PY[ "git" -] = ''' +] = r''' # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (built by setup.py sdist) and build @@ -955,7 +954,7 @@ def git_get_keywords(versionfile_abs): # _version.py. keywords = {} try: - f = open(versionfile_abs, "r") + f = open(versionfile_abs) for line in f.readlines(): if line.strip().startswith("git_refnames ="): mo = re.search(r'=\s*"(.*)"', line) @@ -970,7 +969,7 @@ def git_get_keywords(versionfile_abs): if mo: keywords["date"] = mo.group(1) f.close() - except EnvironmentError: + except OSError: pass return keywords @@ -994,11 +993,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): if verbose: print("keywords are unexpanded, not using") raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = set([r.strip() for r in refnames.strip("()").split(",")]) + refs = {r.strip() for r in refnames.strip("()").split(",")} # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) + tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -1007,7 +1006,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r"\d", r)]) + tags = {r for r in refs if re.search(r"\d", r)} if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -1100,7 +1099,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % (full_tag, tag_prefix) + pieces["error"] = f"tag '{full_tag}' doesn't start with prefix '{tag_prefix}'" return pieces pieces["closest-tag"] = full_tag[len(tag_prefix) :] @@ -1145,13 +1144,13 @@ def do_vcs_install(manifest_in, versionfile_source, ipy): files.append(versioneer_file) present = False try: - f = open(".gitattributes", "r") + f = open(".gitattributes") for line in f.readlines(): if line.strip().startswith(versionfile_source): if "export-subst" in line.strip().split()[1:]: present = True f.close() - except EnvironmentError: + except OSError: pass if not present: f = open(".gitattributes", "a+") @@ -1185,7 +1184,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): root = os.path.dirname(root) # up a level if verbose: - print("Tried directories %s but none started with prefix %s" % (str(rootdirs), parentdir_prefix)) + print(f"Tried directories {rootdirs!s} but none started with prefix {parentdir_prefix}") raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @@ -1212,7 +1211,7 @@ def versions_from_file(filename): try: with open(filename) as f: contents = f.read() - except EnvironmentError: + except OSError: raise NotThisMethod("unable to read _version.py") mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", contents, re.M | re.S) if not mo: @@ -1229,7 +1228,7 @@ def write_to_version_file(filename, versions): with open(filename, "w") as f: f.write(SHORT_VERSION_PY % contents) - print("set %s to '%s'" % (filename, versions["version"])) + print(f"set {filename} to '{versions['version']}'") def plus_or_dot(pieces): @@ -1452,7 +1451,7 @@ def get_versions(verbose=False): try: ver = versions_from_file(versionfile_abs) if verbose: - print("got version from file %s %s" % (versionfile_abs, ver)) + print(f"got version from file {versionfile_abs} {ver}") return ver except NotThisMethod: pass @@ -1723,7 +1722,7 @@ def do_setup(): root = get_root() try: cfg = get_config_from_root(root) - except (EnvironmentError, configparser.NoSectionError, configparser.NoOptionError) as e: + except (OSError, configparser.NoSectionError, configparser.NoOptionError) as e: if isinstance(e, (EnvironmentError, configparser.NoSectionError)): print("Adding sample versioneer config to setup.cfg", file=sys.stderr) with open(os.path.join(root, "setup.cfg"), "a") as f: @@ -1748,9 +1747,9 @@ def do_setup(): ipy = os.path.join(os.path.dirname(cfg.versionfile_source), "__init__.py") if os.path.exists(ipy): try: - with open(ipy, "r") as f: + with open(ipy) as f: old = f.read() - except EnvironmentError: + except OSError: old = "" if INIT_PY_SNIPPET not in old: print(" appending to %s" % ipy) @@ -1769,12 +1768,12 @@ def do_setup(): manifest_in = os.path.join(root, "MANIFEST.in") simple_includes = set() try: - with open(manifest_in, "r") as f: + with open(manifest_in) as f: for line in f: if line.startswith("include "): for include in line.split()[1:]: simple_includes.add(include) - except EnvironmentError: + except OSError: pass # That doesn't cover everything MANIFEST.in can do # (http://docs.python.org/2/distutils/sourcedist.html#commands), so @@ -1805,7 +1804,7 @@ def scan_setup_py(): found = set() setters = False errors = 0 - with open("setup.py", "r") as f: + with open("setup.py") as f: for line in f.readlines(): if "import versioneer" in line: found.add("import") From 196d73a08035cc0d8be243d8f9c76f4265b36057 Mon Sep 17 00:00:00 2001 From: renoyjohnm <168143499+renoyjohnm@users.noreply.github.com> Date: Thu, 24 Oct 2024 13:42:25 -0700 Subject: [PATCH 2/2] Revert "Development to master branch merge" (#1513) Revert "0.34 development merge" This reverts commit 6798b9e5fc27d459fea93b415519331c7adac954. --- .github/workflows/meta-checks.yml | 14 - .github/workflows/run-tests.yml | 16 +- pyproject.toml | 18 +- samples/add_default_permission.py | 4 +- samples/create_group.py | 13 +- samples/create_project.py | 2 +- samples/create_schedules.py | 8 +- samples/explore_datasource.py | 22 +- samples/explore_favorites.py | 16 +- samples/explore_site.py | 2 +- samples/explore_webhooks.py | 4 +- samples/explore_workbook.py | 33 +- samples/export.py | 23 +- samples/extracts.py | 14 +- samples/filter_sort_groups.py | 44 +- samples/filter_sort_projects.py | 2 +- samples/getting_started/1_hello_server.py | 4 +- samples/getting_started/2_hello_site.py | 4 +- samples/getting_started/3_hello_universe.py | 22 +- samples/initialize_server.py | 10 +- samples/list.py | 5 +- samples/login.py | 25 +- samples/move_workbook_sites.py | 8 +- samples/pagination_sample.py | 8 +- samples/publish_datasource.py | 25 +- samples/publish_workbook.py | 4 +- samples/query_permissions.py | 8 +- samples/refresh_tasks.py | 4 +- samples/set_refresh_schedule.py | 2 +- samples/update_connection.py | 2 +- samples/update_workbook_data_acceleration.py | 109 +++ .../update_workbook_data_freshness_policy.py | 2 +- tableauserverclient/__init__.py | 50 +- tableauserverclient/_version.py | 18 +- tableauserverclient/config.py | 8 +- tableauserverclient/models/column_item.py | 2 +- .../models/connection_credentials.py | 2 +- tableauserverclient/models/connection_item.py | 12 +- .../models/custom_view_item.py | 35 +- .../models/data_acceleration_report_item.py | 4 +- tableauserverclient/models/data_alert_item.py | 10 +- .../models/data_freshness_policy_item.py | 12 +- tableauserverclient/models/database_item.py | 6 +- tableauserverclient/models/datasource_item.py | 20 +- tableauserverclient/models/dqw_item.py | 2 +- tableauserverclient/models/favorites_item.py | 11 +- tableauserverclient/models/fileupload_item.py | 2 +- tableauserverclient/models/flow_item.py | 12 +- tableauserverclient/models/flow_run_item.py | 6 +- tableauserverclient/models/group_item.py | 8 +- tableauserverclient/models/groupset_item.py | 8 +- tableauserverclient/models/interval_item.py | 18 +- tableauserverclient/models/job_item.py | 16 +- .../models/linked_tasks_item.py | 10 +- tableauserverclient/models/metric_item.py | 10 +- tableauserverclient/models/pagination_item.py | 2 +- .../models/permissions_item.py | 22 +- tableauserverclient/models/project_item.py | 54 +- .../models/property_decorators.py | 23 +- tableauserverclient/models/reference_item.py | 4 +- tableauserverclient/models/revision_item.py | 6 +- tableauserverclient/models/schedule_item.py | 4 +- .../models/server_info_item.py | 32 +- tableauserverclient/models/site_item.py | 72 +- .../models/subscription_item.py | 6 +- tableauserverclient/models/table_item.py | 2 +- tableauserverclient/models/tableau_auth.py | 120 +-- tableauserverclient/models/tableau_types.py | 4 +- tableauserverclient/models/tag_item.py | 7 +- tableauserverclient/models/task_item.py | 8 +- tableauserverclient/models/user_item.py | 64 +- tableauserverclient/models/view_item.py | 21 +- .../models/virtual_connection_item.py | 11 +- tableauserverclient/models/webhook_item.py | 12 +- tableauserverclient/models/workbook_item.py | 102 +-- tableauserverclient/namespace.py | 2 +- tableauserverclient/server/__init__.py | 3 +- .../server/endpoint/auth_endpoint.py | 73 +- .../server/endpoint/custom_views_endpoint.py | 80 +- .../data_acceleration_report_endpoint.py | 4 +- .../server/endpoint/data_alert_endpoint.py | 28 +- .../server/endpoint/databases_endpoint.py | 25 +- .../server/endpoint/datasources_endpoint.py | 103 +-- .../endpoint/default_permissions_endpoint.py | 37 +- .../server/endpoint/dqw_endpoint.py | 18 +- .../server/endpoint/endpoint.py | 40 +- .../server/endpoint/exceptions.py | 30 +- .../server/endpoint/favorites_endpoint.py | 62 +- .../server/endpoint/fileuploads_endpoint.py | 20 +- .../server/endpoint/flow_runs_endpoint.py | 28 +- .../server/endpoint/flow_task_endpoint.py | 4 +- .../server/endpoint/flows_endpoint.py | 59 +- .../server/endpoint/groups_endpoint.py | 35 +- .../server/endpoint/groupsets_endpoint.py | 4 +- .../server/endpoint/jobs_endpoint.py | 14 +- .../server/endpoint/linked_tasks_endpoint.py | 4 +- .../server/endpoint/metadata_endpoint.py | 4 +- .../server/endpoint/metrics_endpoint.py | 20 +- .../server/endpoint/permissions_endpoint.py | 28 +- .../server/endpoint/projects_endpoint.py | 111 +-- .../server/endpoint/resource_tagger.py | 27 +- .../server/endpoint/schedules_endpoint.py | 35 +- .../server/endpoint/server_info_endpoint.py | 45 +- .../server/endpoint/sites_endpoint.py | 299 +------- .../server/endpoint/subscriptions_endpoint.py | 20 +- .../server/endpoint/tables_endpoint.py | 29 +- .../server/endpoint/tasks_endpoint.py | 16 +- .../server/endpoint/users_endpoint.py | 385 +--------- .../server/endpoint/views_endpoint.py | 37 +- .../endpoint/virtual_connections_endpoint.py | 11 +- .../server/endpoint/webhooks_endpoint.py | 22 +- .../server/endpoint/workbooks_endpoint.py | 708 ++---------------- tableauserverclient/server/filter.py | 4 +- tableauserverclient/server/pager.py | 11 +- tableauserverclient/server/query.py | 87 +-- tableauserverclient/server/request_factory.py | 73 +- tableauserverclient/server/request_options.py | 268 ++++--- tableauserverclient/server/server.py | 74 +- tableauserverclient/server/sort.py | 4 +- test/_utils.py | 14 - test/assets/flow_runs_get.xml | 3 +- test/assets/server_info_wrong_site.html | 56 -- test/test_auth.py | 6 +- test/test_custom_view.py | 72 -- test/test_dataalert.py | 2 +- test/test_datasource.py | 10 +- test/test_endpoint.py | 2 +- test/test_favorites.py | 18 +- test/test_filesys_helpers.py | 2 +- test/test_fileuploads.py | 6 +- test/test_flowruns.py | 23 +- test/test_flowtask.py | 2 +- test/test_group.py | 1 + test/test_job.py | 8 +- test/test_pager.py | 12 - test/test_project.py | 36 +- test/test_regression_tests.py | 6 +- test/test_request_option.py | 24 +- test/test_schedule.py | 18 +- test/test_server_info.py | 10 - test/test_site_model.py | 2 + test/test_tagging.py | 4 +- test/test_task.py | 8 +- test/test_user.py | 7 +- test/test_user_model.py | 9 +- test/test_view.py | 6 +- test/test_view_acceleration.py | 2 +- test/test_workbook.py | 12 +- versioneer.py | 47 +- 149 files changed, 1407 insertions(+), 3347 deletions(-) create mode 100644 samples/update_workbook_data_acceleration.py delete mode 100644 test/assets/server_info_wrong_site.html diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml index 0e2b425ee..41a944e63 100644 --- a/.github/workflows/meta-checks.yml +++ b/.github/workflows/meta-checks.yml @@ -13,20 +13,6 @@ 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 2e197cf20..d70539582 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,25 +13,11 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] 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 08f90c49c..3bf47ea23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,42 +14,42 @@ 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.32', # latest as at 7/31/23 - 'urllib3>=2.2.2,<3', + 'requests>=2.31', # latest as at 7/31/23 + 'urllib3==2.2.2', # dependabot 'typing_extensions>=4.0.1', ] -requires-python = ">=3.9" +requires-python = ">=3.7" classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13" + "Programming Language :: Python :: 3.12" ] [project.urls] repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["black==24.8", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", +test = ["black==23.7", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", "requests-mock>=1.0,<2.0"] [tool.black] line-length = 120 -target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] +target-version = ['py37', 'py38', 'py39', 'py310', 'py311', 'py312'] [tool.mypy] 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"] +files = ["tableauserverclient", "test"] 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/add_default_permission.py b/samples/add_default_permission.py index d26d009e2..5a450e8ab 100644 --- a/samples/add_default_permission.py +++ b/samples/add_default_permission.py @@ -63,10 +63,10 @@ def main(): for permission in new_default_permissions: grantee = permission.grantee capabilities = permission.capabilities - print(f"\nCapabilities for {grantee.tag_name} {grantee.id}:") + print("\nCapabilities for {0} {1}:".format(grantee.tag_name, grantee.id)) for capability in capabilities: - print(f"\t{capability} - {capabilities[capability]}") + print("\t{0} - {1}".format(capability, capabilities[capability])) # Uncomment lines below to DELETE the new capability and the new project # rules_to_delete = TSC.PermissionsRule( diff --git a/samples/create_group.py b/samples/create_group.py index aca3e895b..f4c6a9ca9 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -11,6 +11,7 @@ import os from datetime import time +from typing import List import tableauserverclient as TSC from tableauserverclient import ServerResponseError @@ -62,23 +63,23 @@ def main(): if args.file: filepath = os.path.abspath(args.file) - print(f"Add users to site from file {filepath}:") - added: list[TSC.UserItem] - failed: list[TSC.UserItem, TSC.ServerResponseError] + print("Add users to site from file {}:".format(filepath)) + added: List[TSC.UserItem] + failed: List[TSC.UserItem, TSC.ServerResponseError] added, failed = server.users.create_from_file(filepath) for user, error in failed: print(user, error.code) if error.code == "409017": user = server.users.filter(name=user.name)[0] added.append(user) - print(f"Adding users to group:{added}") + print("Adding users to group:{}".format(added)) for user in added: - print(f"Adding user {user}") + print("Adding user {}".format(user)) try: server.groups.add_user(group, user.id) except ServerResponseError as serverError: if serverError.code == "409011": - print(f"user {user.name} is already a member of group {group.name}") + print("user {} is already a member of group {}".format(user.name, group.name)) else: raise rError diff --git a/samples/create_project.py b/samples/create_project.py index d775902aa..1fc649f8c 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -84,7 +84,7 @@ def main(): server.projects.populate_datasource_default_permissions(changed_project), server.projects.populate_permissions(changed_project) # Projects have default permissions set for the object types they contain - print(f"Permissions from project {changed_project.id}:") + print("Permissions from project {}:".format(changed_project.id)) print(changed_project.permissions) print( changed_project.default_workbook_permissions, diff --git a/samples/create_schedules.py b/samples/create_schedules.py index c23a2eced..dee088571 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -55,7 +55,7 @@ def main(): ) try: hourly_schedule = server.schedules.create(hourly_schedule) - print(f"Hourly schedule created (ID: {hourly_schedule.id}).") + print("Hourly schedule created (ID: {}).".format(hourly_schedule.id)) except Exception as e: print(e) @@ -71,7 +71,7 @@ def main(): ) try: daily_schedule = server.schedules.create(daily_schedule) - print(f"Daily schedule created (ID: {daily_schedule.id}).") + print("Daily schedule created (ID: {}).".format(daily_schedule.id)) except Exception as e: print(e) @@ -89,7 +89,7 @@ def main(): ) try: weekly_schedule = server.schedules.create(weekly_schedule) - print(f"Weekly schedule created (ID: {weekly_schedule.id}).") + print("Weekly schedule created (ID: {}).".format(weekly_schedule.id)) except Exception as e: print(e) options = TSC.RequestOptions() @@ -112,7 +112,7 @@ def main(): ) try: monthly_schedule = server.schedules.create(monthly_schedule) - print(f"Monthly schedule created (ID: {monthly_schedule.id}).") + print("Monthly schedule created (ID: {}).".format(monthly_schedule.id)) except Exception as e: print(e) diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index c9f35d5be..fb45cb45e 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -51,17 +51,16 @@ def main(): if args.publish: if default_project is not None: new_datasource = TSC.DatasourceItem(default_project.id) - new_datasource.description = "Published with a description" new_datasource = server.datasources.publish( new_datasource, args.publish, TSC.Server.PublishMode.Overwrite ) - print(f"Datasource published. ID: {new_datasource.id}") + print("Datasource published. ID: {}".format(new_datasource.id)) else: print("Publish failed. Could not find the default project.") # Gets all datasource items all_datasources, pagination_item = server.datasources.get() - print(f"\nThere are {pagination_item.total_available} datasources on site: ") + print("\nThere are {} datasources on site: ".format(pagination_item.total_available)) print([datasource.name for datasource in all_datasources]) if all_datasources: @@ -70,19 +69,20 @@ def main(): # Populate connections server.datasources.populate_connections(sample_datasource) - print(f"\nConnections for {sample_datasource.name}: ") - print([f"{connection.id}({connection.datasource_name})" for connection in sample_datasource.connections]) - - # Demonstrate that description is editable - sample_datasource.description = "Description updated by TSC" - server.datasources.update(sample_datasource) + print("\nConnections for {}: ".format(sample_datasource.name)) + print( + [ + "{0}({1})".format(connection.id, connection.datasource_name) + for connection in sample_datasource.connections + ] + ) # Add some tags to the datasource original_tag_set = set(sample_datasource.tags) sample_datasource.tags.update("a", "b", "c", "d") server.datasources.update(sample_datasource) - print(f"\nOld tag set: {original_tag_set}") - print(f"New tag set: {sample_datasource.tags}") + print("\nOld tag set: {}".format(original_tag_set)) + print("New tag set: {}".format(sample_datasource.tags)) # Delete all tags that were added by setting tags to original sample_datasource.tags = original_tag_set diff --git a/samples/explore_favorites.py b/samples/explore_favorites.py index f199522ed..243e91954 100644 --- a/samples/explore_favorites.py +++ b/samples/explore_favorites.py @@ -3,7 +3,7 @@ import argparse import logging import tableauserverclient as TSC -from tableauserverclient.models import Resource +from tableauserverclient import Resource def main(): @@ -39,15 +39,15 @@ def main(): # get all favorites on site for the logged on user user: TSC.UserItem = TSC.UserItem() user.id = server.user_id - print(f"Favorites for user: {user.id}") + print("Favorites for user: {}".format(user.id)) server.favorites.get(user) print(user.favorites) # get list of workbooks all_workbook_items, pagination_item = server.workbooks.get() if all_workbook_items is not None and len(all_workbook_items) > 0: - my_workbook = all_workbook_items[0] - server.favorites.add_favorite(user, Resource.Workbook, all_workbook_items[0]) + my_workbook: TSC.WorkbookItem = all_workbook_items[0] + server.favorites.add_favorite(server, user, Resource.Workbook.name(), all_workbook_items[0]) print( "Workbook added to favorites. Workbook Name: {}, Workbook ID: {}".format( my_workbook.name, my_workbook.id @@ -57,7 +57,7 @@ def main(): if views is not None and len(views) > 0: my_view = views[0] server.favorites.add_favorite_view(user, my_view) - print(f"View added to favorites. View Name: {my_view.name}, View ID: {my_view.id}") + print("View added to favorites. View Name: {}, View ID: {}".format(my_view.name, my_view.id)) all_datasource_items, pagination_item = server.datasources.get() if all_datasource_items: @@ -70,10 +70,12 @@ def main(): ) server.favorites.delete_favorite_workbook(user, my_workbook) - print(f"Workbook deleted from favorites. Workbook Name: {my_workbook.name}, Workbook ID: {my_workbook.id}") + print( + "Workbook deleted from favorites. Workbook Name: {}, Workbook ID: {}".format(my_workbook.name, my_workbook.id) + ) server.favorites.delete_favorite_view(user, my_view) - print(f"View deleted from favorites. View Name: {my_view.name}, View ID: {my_view.id}") + print("View deleted from favorites. View Name: {}, View ID: {}".format(my_view.name, my_view.id)) server.favorites.delete_favorite_datasource(user, my_datasource) print( diff --git a/samples/explore_site.py b/samples/explore_site.py index eb9eba0de..a2274f1a7 100644 --- a/samples/explore_site.py +++ b/samples/explore_site.py @@ -49,7 +49,7 @@ def main(): if args.delete: print("You can only delete the site you are currently in") - print(f"Delete site `{current_site.name}`?") + print("Delete site `{}`?".format(current_site.name)) # server.sites.delete(server.site_id) elif args.create: diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index f25c41849..77802b1db 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -52,11 +52,11 @@ def main(): new_webhook.event = "datasource-created" print(new_webhook) new_webhook = server.webhooks.create(new_webhook) - print(f"Webhook created. ID: {new_webhook.id}") + print("Webhook created. ID: {}".format(new_webhook.id)) # Gets all webhook items all_webhooks, pagination_item = server.webhooks.get() - print(f"\nThere are {pagination_item.total_available} webhooks on site: ") + print("\nThere are {} webhooks on site: ".format(pagination_item.total_available)) print([webhook.name for webhook in all_webhooks]) if all_webhooks: diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index f51639ab3..57f88aa07 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -59,13 +59,13 @@ def main(): if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) new_workbook = server.workbooks.publish(new_workbook, args.publish, overwrite_true) - print(f"Workbook published. ID: {new_workbook.id}") + print("Workbook published. ID: {}".format(new_workbook.id)) else: print("Publish failed. Could not find the default project.") # Gets all workbook items all_workbooks, pagination_item = server.workbooks.get() - print(f"\nThere are {pagination_item.total_available} workbooks on site: ") + print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) print([workbook.name for workbook in all_workbooks]) if all_workbooks: @@ -78,22 +78,27 @@ def main(): # Populate views server.workbooks.populate_views(sample_workbook) - print(f"\nName of views in {sample_workbook.name}: ") + print("\nName of views in {}: ".format(sample_workbook.name)) print([view.name for view in sample_workbook.views]) # Populate connections server.workbooks.populate_connections(sample_workbook) - print(f"\nConnections for {sample_workbook.name}: ") - print([f"{connection.id}({connection.datasource_name})" for connection in sample_workbook.connections]) + print("\nConnections for {}: ".format(sample_workbook.name)) + print( + [ + "{0}({1})".format(connection.id, connection.datasource_name) + for connection in sample_workbook.connections + ] + ) # Update tags and show_tabs flag original_tag_set = set(sample_workbook.tags) sample_workbook.tags.update("a", "b", "c", "d") sample_workbook.show_tabs = True server.workbooks.update(sample_workbook) - print(f"\nWorkbook's old tag set: {original_tag_set}") - print(f"Workbook's new tag set: {sample_workbook.tags}") - print(f"Workbook tabbed: {sample_workbook.show_tabs}") + print("\nWorkbook's old tag set: {}".format(original_tag_set)) + print("Workbook's new tag set: {}".format(sample_workbook.tags)) + print("Workbook tabbed: {}".format(sample_workbook.show_tabs)) # Delete all tags that were added by setting tags to original sample_workbook.tags = original_tag_set @@ -104,8 +109,8 @@ def main(): original_tag_set = set(sample_view.tags) sample_view.tags.add("view_tag") server.views.update(sample_view) - print(f"\nView's old tag set: {original_tag_set}") - print(f"View's new tag set: {sample_view.tags}") + print("\nView's old tag set: {}".format(original_tag_set)) + print("View's new tag set: {}".format(sample_view.tags)) # Delete tag from just one view sample_view.tags = original_tag_set @@ -114,14 +119,14 @@ def main(): if args.download: # Download path = server.workbooks.download(sample_workbook.id, args.download) - print(f"\nDownloaded workbook to {path}") + print("\nDownloaded workbook to {}".format(path)) if args.preview_image: # Populate workbook preview image server.workbooks.populate_preview_image(sample_workbook) with open(args.preview_image, "wb") as f: f.write(sample_workbook.preview_image) - print(f"\nDownloaded preview image of workbook to {os.path.abspath(args.preview_image)}") + print("\nDownloaded preview image of workbook to {}".format(os.path.abspath(args.preview_image))) # get custom views cvs, _ = server.custom_views.get() @@ -148,10 +153,10 @@ def main(): server.workbooks.populate_powerpoint(sample_workbook) with open(args.powerpoint, "wb") as f: f.write(sample_workbook.powerpoint) - print(f"\nDownloaded powerpoint of workbook to {os.path.abspath(args.powerpoint)}") + print("\nDownloaded powerpoint of workbook to {}".format(os.path.abspath(args.powerpoint))) if args.delete: - print(f"deleting {c.id}") + print("deleting {}".format(c.id)) unlucky = TSC.CustomViewItem(c.id) server.custom_views.delete(unlucky.id) diff --git a/samples/export.py b/samples/export.py index b2506cf46..f2783fa6e 100644 --- a/samples/export.py +++ b/samples/export.py @@ -37,11 +37,8 @@ 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") @@ -59,16 +56,14 @@ 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) if not item: - print(f"No item found for id {args.resource_id}") + print("No item found for id {}".format(args.resource_id)) exit(1) - print(f"Item found: {item.name}") + print("Item found: {}".format(item.name)) # We have a number of different types and functions for each different export type. # We encode that information above in the const=(...) parameter to the add_argument function to make # the code automatically adapt for the type of export the user is doing. @@ -77,22 +72,18 @@ 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 = options.vf(*args.filter.split(":")) - - if args.language: - options.language = args.language + options = option_factory().vf(*args.filter.split(":")) + else: + options = None if args.file: filename = args.file else: - filename = f"out-{options.language}.{extension}" + filename = "out.{}".format(extension) populate(item, options) with open(filename, "wb") as f: diff --git a/samples/extracts.py b/samples/extracts.py index c0dd885bc..9bd87a473 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -1,7 +1,13 @@ #### -# 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. -##### +# 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. +#### import argparse import logging @@ -41,7 +47,7 @@ def main(): with server.auth.sign_in(tableau_auth): # Gets all workbook items all_workbooks, pagination_item = server.workbooks.get() - print(f"\nThere are {pagination_item.total_available} workbooks on site: ") + print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) print([workbook.name for workbook in all_workbooks]) if all_workbooks: diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index 1694bf0f5..042af32e2 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, http_options={"verify": False}) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): group_name = "SALES NORTHWEST" # Try to create a group named "SALES NORTHWEST" @@ -57,36 +57,37 @@ def main(): # Try to create a group named "SALES ROMANIA" create_example_group(group_name, server) - # we no longer need to encode the space + # 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) options = TSC.RequestOptions() - options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, group_name)) + options.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, filter_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 = filtered_groups.pop() - print(group) + group_name = filtered_groups.pop().name + print(group_name) else: - error = f"No group named '{group_name}' found" + error = "No project named '{}' found".format(filter_group_name) print(error) - print("---") - # Or, try the above with the django style filtering try: - group = server.groups.filter(name=group_name)[0] - print(group) + group = server.groups.filter(name=filter_group_name)[0] except IndexError: - print(f"No group named '{group_name}' found") - - print("====") + print(f"No project named '{filter_group_name}' found") + else: + print(group.name) 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"], ) ) @@ -97,19 +98,12 @@ def main(): for group in matching_groups: print(group.name) - print("----") # or, try the above with the django style filtering. - 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") + 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"): + print(group.name) if __name__ == "__main__": diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index 6c3a85dcd..7aa62a5c1 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -68,7 +68,7 @@ def main(): project_name = filtered_projects.pop().name print(project_name) else: - error = f"No project named '{filter_project_name}' found" + error = "No project named '{}' found".format(filter_project_name) print(error) create_example_project(name="Example 1", server=server) diff --git a/samples/getting_started/1_hello_server.py b/samples/getting_started/1_hello_server.py index 5f8cfa238..454b225de 100644 --- a/samples/getting_started/1_hello_server.py +++ b/samples/getting_started/1_hello_server.py @@ -12,8 +12,8 @@ def main(): # This is the domain for Tableau's Developer Program server_url = "https://10ax.online.tableau.com" server = TSC.Server(server_url) - print(f"Connected to {server.server_info.baseurl}") - print(f"Server information: {server.server_info}") + print("Connected to {}".format(server.server_info.baseurl)) + print("Server information: {}".format(server.server_info)) print("Sign up for a test site at https://www.tableau.com/developer") diff --git a/samples/getting_started/2_hello_site.py b/samples/getting_started/2_hello_site.py index 8635947a8..d62896059 100644 --- a/samples/getting_started/2_hello_site.py +++ b/samples/getting_started/2_hello_site.py @@ -19,7 +19,7 @@ def main(): use_ssl = True server = TSC.Server(server_url, use_server_version=True, http_options={"verify": use_ssl}) - print(f"Connected to {server.server_info.baseurl}") + print("Connected to {}".format(server.server_info.baseurl)) # 3 - replace with your site name exactly as it looks in the url # e.g https://my-server/#/site/this-is-your-site-url-name/not-this-part @@ -39,7 +39,7 @@ def main(): with server.auth.sign_in(tableau_auth): projects, pagination = server.projects.get() if projects: - print(f"{pagination.total_available} projects") + print("{} projects".format(pagination.total_available)) project = projects[0] print(project.name) diff --git a/samples/getting_started/3_hello_universe.py b/samples/getting_started/3_hello_universe.py index a2c4301d0..21de97831 100644 --- a/samples/getting_started/3_hello_universe.py +++ b/samples/getting_started/3_hello_universe.py @@ -17,7 +17,7 @@ def main(): use_ssl = True server = TSC.Server(server_url, use_server_version=True, http_options={"verify": use_ssl}) - print(f"Connected to {server.server_info.baseurl}") + print("Connected to {}".format(server.server_info.baseurl)) # 3 - replace with your site name exactly as it looks in a url # e.g https://my-server/#/this-is-your-site-url-name/ @@ -36,55 +36,55 @@ def main(): with server.auth.sign_in(tableau_auth): projects, pagination = server.projects.get() if projects: - print(f"{pagination.total_available} projects") + print("{} projects".format(pagination.total_available)) for project in projects: print(project.name) workbooks, pagination = server.datasources.get() if workbooks: - print(f"{pagination.total_available} workbooks") + print("{} workbooks".format(pagination.total_available)) print(workbooks[0]) views, pagination = server.views.get() if views: - print(f"{pagination.total_available} views") + print("{} views".format(pagination.total_available)) print(views[0]) datasources, pagination = server.datasources.get() if datasources: - print(f"{pagination.total_available} datasources") + print("{} datasources".format(pagination.total_available)) print(datasources[0]) # I think all these other content types can go to a hello_universe script # data alert, dqw, flow, ... do any of these require any add-ons? jobs, pagination = server.jobs.get() if jobs: - print(f"{pagination.total_available} jobs") + print("{} jobs".format(pagination.total_available)) print(jobs[0]) schedules, pagination = server.schedules.get() if schedules: - print(f"{pagination.total_available} schedules") + print("{} schedules".format(pagination.total_available)) print(schedules[0]) tasks, pagination = server.tasks.get() if tasks: - print(f"{pagination.total_available} tasks") + print("{} tasks".format(pagination.total_available)) print(tasks[0]) webhooks, pagination = server.webhooks.get() if webhooks: - print(f"{pagination.total_available} webhooks") + print("{} webhooks".format(pagination.total_available)) print(webhooks[0]) users, pagination = server.users.get() if users: - print(f"{pagination.total_available} users") + print("{} users".format(pagination.total_available)) print(users[0]) groups, pagination = server.groups.get() if groups: - print(f"{pagination.total_available} groups") + print("{} groups".format(pagination.total_available)) print(groups[0]) diff --git a/samples/initialize_server.py b/samples/initialize_server.py index cdfaf27a8..cb3d9e1d0 100644 --- a/samples/initialize_server.py +++ b/samples/initialize_server.py @@ -51,7 +51,7 @@ def main(): # Create the site if it doesn't exist if existing_site is None: - print(f"Site not found: {args.site_id} Creating it...") + print("Site not found: {0} Creating it...".format(args.site_id)) new_site = TSC.SiteItem( name=args.site_id, content_url=args.site_id.replace(" ", ""), @@ -59,7 +59,7 @@ def main(): ) server.sites.create(new_site) else: - print(f"Site {args.site_id} exists. Moving on...") + print("Site {0} exists. Moving on...".format(args.site_id)) ################################################################################ # Step 3: Sign-in to our target site @@ -81,7 +81,7 @@ def main(): # Create our project if it doesn't exist if project is None: - print(f"Project not found: {args.project} Creating it...") + print("Project not found: {0} Creating it...".format(args.project)) new_project = TSC.ProjectItem(name=args.project) project = server_upload.projects.create(new_project) @@ -100,7 +100,7 @@ def publish_datasources_to_site(server_object, project, folder): for fname in glob.glob(path): new_ds = TSC.DatasourceItem(project.id) new_ds = server_object.datasources.publish(new_ds, fname, server_object.PublishMode.Overwrite) - print(f"Datasource published. ID: {new_ds.id}") + print("Datasource published. ID: {0}".format(new_ds.id)) def publish_workbooks_to_site(server_object, project, folder): @@ -110,7 +110,7 @@ def publish_workbooks_to_site(server_object, project, folder): new_workbook = TSC.WorkbookItem(project.id) new_workbook.show_tabs = True new_workbook = server_object.workbooks.publish(new_workbook, fname, server_object.PublishMode.Overwrite) - print(f"Workbook published. ID: {new_workbook.id}") + print("Workbook published. ID: {0}".format(new_workbook.id)) if __name__ == "__main__": diff --git a/samples/list.py b/samples/list.py index 2675a2954..8d72fb620 100644 --- a/samples/list.py +++ b/samples/list.py @@ -48,9 +48,6 @@ def main(): "webhooks": server.webhooks, "workbook": server.workbooks, }.get(args.resource_type) - if endpoint is None: - print("Resource type not found.") - sys.exit(1) options = TSC.RequestOptions() options.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Desc)) @@ -62,7 +59,7 @@ def main(): print(resource.name[:18], " ") # , resource._connections()) if count > 100: break - print(f"Total: {count}") + print("Total: {}".format(count)) if __name__ == "__main__": diff --git a/samples/login.py b/samples/login.py index bc99385b3..6a3e9e8b3 100644 --- a/samples/login.py +++ b/samples/login.py @@ -7,15 +7,9 @@ import argparse import getpass import logging -import os import tableauserverclient as TSC - - -def get_env(key): - if key in os.environ: - return os.environ[key] - return None +import env # If a sample has additional arguments, then it should copy this code and insert them after the call to @@ -26,13 +20,13 @@ def set_up_and_log_in(): sample_define_common_options(parser) args = parser.parse_args() if not args.server: - args.server = get_env("SERVER") + args.server = env.server if not args.site: - args.site = get_env("SITE") + args.site = env.site if not args.token_name: - args.token_name = get_env("TOKEN_NAME") + args.token_name = env.token_name if not args.token_value: - args.token_value = get_env("TOKEN_VALUE") + args.token_value = env.token_value args.logging_level = "debug" server = sample_connect_to_server(args) @@ -65,7 +59,7 @@ def sample_connect_to_server(args): password = args.password or getpass.getpass("Password: ") tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.site) - print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nUsername: {args.username}") + print("\nSigning in...\nServer: {}\nSite: {}\nUsername: {}".format(args.server, args.site, args.username)) else: # Trying to authenticate using personal access tokens. @@ -74,7 +68,7 @@ def sample_connect_to_server(args): tableau_auth = TSC.PersonalAccessTokenAuth( token_name=args.token_name, personal_access_token=token, site_id=args.site ) - print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nToken name: {args.token_name}") + print("\nSigning in...\nServer: {}\nSite: {}\nToken name: {}".format(args.server, args.site, args.token_name)) if not tableau_auth: raise TabError("Did not create authentication object. Check arguments.") @@ -85,7 +79,10 @@ 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 = "3.19" + 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") return server diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index e82c75cf9..47af1f2f9 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -59,7 +59,7 @@ def main(): # Step 3: Download workbook to a temp directory if len(all_workbooks) == 0: - print(f"No workbook named {args.workbook_name} found.") + print("No workbook named {} found.".format(args.workbook_name)) else: tmpdir = tempfile.mkdtemp() try: @@ -68,10 +68,10 @@ def main(): # Step 4: Check if destination site exists, then sign in to the site all_sites, pagination_info = source_server.sites.get() found_destination_site = any( - True for site in all_sites if args.destination_site.lower() == site.content_url.lower() + (True for site in all_sites if args.destination_site.lower() == site.content_url.lower()) ) if not found_destination_site: - error = f"No site named {args.destination_site} found." + error = "No site named {} found.".format(args.destination_site) raise LookupError(error) tableau_auth.site_id = args.destination_site @@ -85,7 +85,7 @@ def main(): new_workbook = dest_server.workbooks.publish( new_workbook, workbook_path, mode=TSC.Server.PublishMode.Overwrite ) - print(f"Successfully moved {new_workbook.name} ({new_workbook.id})") + print("Successfully moved {0} ({1})".format(new_workbook.name, new_workbook.id)) # Step 6: Delete workbook from source site and delete temp directory source_server.workbooks.delete(all_workbooks[0].id) diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index a68eed4b3..a7ae6dc89 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -57,7 +57,7 @@ def main(): for wb in TSC.Pager(server.workbooks, page_options): print(wb.name) count = count + 1 - print(f"Total: {count}\n") + print("Total: {}\n".format(count)) count = 0 page_options = TSC.RequestOptions(2, 3) @@ -65,7 +65,7 @@ def main(): for wb in TSC.Pager(server.workbooks, page_options): print(wb.name) count = count + 1 - print(f"Truncated Total: {count}\n") + print("Truncated Total: {}\n".format(count)) print("Your id: ", you.name, you.id, "\n") count = 0 @@ -76,7 +76,7 @@ def main(): for wb in TSC.Pager(server.workbooks, filtered_page_options): print(wb.name, " -- ", wb.owner_id) count = count + 1 - print(f"Filtered Total: {count}\n") + print("Filtered Total: {}\n".format(count)) # 2. QuerySet offers a fluent interface on top of the RequestOptions object print("Fetching workbooks again - this time filtered with QuerySet") @@ -90,7 +90,7 @@ def main(): count = count + 1 more = queryset.total_available > count page = page + 1 - print(f"QuerySet Total: {count}") + print("QuerySet Total: {}".format(count)) # 3. QuerySet also allows you to iterate over all objects without explicitly paging. print("Fetching again - this time without manually paging") diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index c674e6882..5ac768674 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -21,15 +21,10 @@ import argparse import logging -import os import tableauserverclient as TSC -import tableauserverclient.datetime_helpers - -def get_env(key): - if key in os.environ: - return os.environ[key] - return None +import env +import tableauserverclient.datetime_helpers def main(): @@ -57,13 +52,13 @@ def main(): args = parser.parse_args() if not args.server: - args.server = get_env("SERVER") + args.server = env.server if not args.site: - args.site = get_env("SITE") + args.site = env.site if not args.token_name: - args.token_name = get_env("TOKEN_NAME") + args.token_name = env.token_name if not args.token_value: - args.token_value = get_env("TOKEN_VALUE") + args.token_value = env.token_value args.logging = "debug" args.file = "C:/dev/tab-samples/5M.tdsx" args.async_ = True @@ -116,17 +111,15 @@ def main(): new_job = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds, as_job=True ) - print(f"Datasource published asynchronously. Job ID: {new_job.id}") + print("Datasource published asynchronously. Job ID: {0}".format(new_job.id)) else: # Normal publishing, returns a datasource_item new_datasource = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds ) print( - ( - "{}Datasource published. Datasource ID: {}".format( - new_datasource.id, tableauserverclient.datetime_helpers.timestamp() - ) + "{0}Datasource published. Datasource ID: {1}".format( + new_datasource.id, tableauserverclient.datetime_helpers.timestamp() ) ) print("\t\tClosing connection") diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index d31978c0f..8a9f45279 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -80,7 +80,7 @@ def main(): as_job=args.as_job, skip_connection_check=args.skip_connection_check, ) - print(f"Workbook published. JOB ID: {new_job.id}") + print("Workbook published. JOB ID: {0}".format(new_job.id)) else: new_workbook = server.workbooks.publish( new_workbook, @@ -90,7 +90,7 @@ def main(): as_job=args.as_job, skip_connection_check=args.skip_connection_check, ) - print(f"Workbook published. ID: {new_workbook.id}") + print("Workbook published. ID: {0}".format(new_workbook.id)) else: error = "The default project could not be found." raise LookupError(error) diff --git a/samples/query_permissions.py b/samples/query_permissions.py index 3309acd90..4e509cd97 100644 --- a/samples/query_permissions.py +++ b/samples/query_permissions.py @@ -57,15 +57,17 @@ def main(): permissions = resource.permissions # Print result - print(f"\n{len(permissions)} permission rule(s) found for {args.resource_type} {args.resource_id}.") + print( + "\n{0} permission rule(s) found for {1} {2}.".format(len(permissions), args.resource_type, args.resource_id) + ) for permission in permissions: grantee = permission.grantee capabilities = permission.capabilities - print(f"\nCapabilities for {grantee.tag_name} {grantee.id}:") + print("\nCapabilities for {0} {1}:".format(grantee.tag_name, grantee.id)) for capability in capabilities: - print(f"\t{capability} - {capabilities[capability]}") + print("\t{0} - {1}".format(capability, capabilities[capability])) if __name__ == "__main__": diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index c95000898..03daedf16 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -19,12 +19,12 @@ def handle_run(server, args): def handle_list(server, _): tasks, pagination = server.tasks.get() for task in tasks: - print(f"{task}") + print("{}".format(task)) def handle_info(server, args): task = server.tasks.get_by_id(args.id) - print(f"{task}") + print("{}".format(task)) def main(): diff --git a/samples/set_refresh_schedule.py b/samples/set_refresh_schedule.py index 153bb0ee5..56fd12e62 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 list(kwargs.items()): + for item, value in 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/update_connection.py b/samples/update_connection.py index 0fe2f342c..4af6592bc 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([x for x in resource.connections if x.id == args.connection_id]) + connections = list(filter(lambda x: x.id == args.connection_id, resource.connections)) 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 new file mode 100644 index 000000000..75f12262f --- /dev/null +++ b/samples/update_workbook_data_acceleration.py @@ -0,0 +1,109 @@ +#### +# 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("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) + 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/samples/update_workbook_data_freshness_policy.py b/samples/update_workbook_data_freshness_policy.py index c23e3717f..9e4d63dc1 100644 --- a/samples/update_workbook_data_freshness_policy.py +++ b/samples/update_workbook_data_freshness_policy.py @@ -45,7 +45,7 @@ def main(): 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("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) print([workbook.name for workbook in all_workbooks]) if all_workbooks: diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index e0a7abb64..bab2cf05f 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -32,13 +32,11 @@ PermissionsRule, PersonalAccessTokenAuth, ProjectItem, - Resource, RevisionItem, ScheduleItem, SiteItem, ServerInfoItem, SubscriptionItem, - TableauItem, TableItem, TableauAuth, Target, @@ -58,7 +56,6 @@ PDFRequestOptions, RequestOptions, MissingRequiredFieldError, - FailedSignInError, NotSignedInError, ServerResponseError, Filter, @@ -68,68 +65,65 @@ ) __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", - "get_versions", + "FileuploadItem", "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", - "Server", - "ServerInfoItem", - "ServerResponseError", "SiteItem", - "Sort", + "ServerInfoItem", "SubscriptionItem", - "TableauAuth", - "TableauItem", "TableItem", + "TableauAuth", "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/_version.py b/tableauserverclient/_version.py index 79dbed1d8..d47374097 100644 --- a/tableauserverclient/_version.py +++ b/tableauserverclient/_version.py @@ -84,7 +84,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= stderr=(subprocess.PIPE if hide_stderr else None), ) break - except OSError: + except EnvironmentError: e = sys.exc_info()[1] if e.errno == errno.ENOENT: continue @@ -94,7 +94,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= return None, None else: if verbose: - print(f"unable to find command, tried {commands}") + print("unable to find command, tried %s" % (commands,)) return None, None stdout = p.communicate()[0].strip() if sys.version_info[0] >= 3: @@ -131,7 +131,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): root = os.path.dirname(root) # up a level if verbose: - print(f"Tried directories {str(rootdirs)} but none started with prefix {parentdir_prefix}") + print("Tried directories %s but none started with prefix %s" % (str(rootdirs), parentdir_prefix)) raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @@ -144,7 +144,7 @@ def git_get_keywords(versionfile_abs): # _version.py. keywords = {} try: - f = open(versionfile_abs) + f = open(versionfile_abs, "r") for line in f.readlines(): if line.strip().startswith("git_refnames ="): mo = re.search(r'=\s*"(.*)"', line) @@ -159,7 +159,7 @@ def git_get_keywords(versionfile_abs): if mo: keywords["date"] = mo.group(1) f.close() - except OSError: + except EnvironmentError: pass return keywords @@ -183,11 +183,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): if verbose: print("keywords are unexpanded, not using") raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = {r.strip() for r in refnames.strip("()").split(",")} + refs = set([r.strip() for r in refnames.strip("()").split(",")]) # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} + tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -196,7 +196,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = {r for r in refs if re.search(r"\d", r)} + tags = set([r for r in refs if re.search(r"\d", r)]) if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -299,7 +299,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = "tag '{}' doesn't start with prefix '{}'".format( + pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % ( full_tag, tag_prefix, ) diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py index a75112754..63872398f 100644 --- a/tableauserverclient/config.py +++ b/tableauserverclient/config.py @@ -6,13 +6,11 @@ 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) +class Config: # 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/column_item.py b/tableauserverclient/models/column_item.py index 3a7416e28..df936e315 100644 --- a/tableauserverclient/models/column_item.py +++ b/tableauserverclient/models/column_item.py @@ -3,7 +3,7 @@ from .property_decorators import property_not_empty -class ColumnItem: +class ColumnItem(object): def __init__(self, name, description=None): self._id = None self.description = description diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py index bb2cbbba9..d61bbb751 100644 --- a/tableauserverclient/models/connection_credentials.py +++ b/tableauserverclient/models/connection_credentials.py @@ -1,7 +1,7 @@ from .property_decorators import property_is_boolean -class ConnectionCredentials: +class ConnectionCredentials(object): """Connection Credentials for Workbooks and Datasources publish request. Consider removing this object and other variables holding secrets diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 937e43481..62ff530c9 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -1,5 +1,5 @@ import logging -from typing import Optional +from typing import List, Optional from defusedxml.ElementTree import fromstring @@ -8,7 +8,7 @@ from tableauserverclient.helpers.logging import logger -class ConnectionItem: +class ConnectionItem(object): def __init__(self): self._datasource_id: Optional[str] = None self._datasource_name: Optional[str] = None @@ -48,7 +48,7 @@ def query_tagging(self, value: Optional[bool]): # if connection type = hyper, Snowflake, or Teradata, we can't change this value: it is always true if self._connection_type in ["hyper", "snowflake", "teradata"]: logger.debug( - f"Cannot update value: Query tagging is always enabled for {self._connection_type} connections" + "Cannot update value: Query tagging is always enabled for {} connections".format(self._connection_type) ) return self._query_tagging = value @@ -59,7 +59,7 @@ def __repr__(self): ) @classmethod - def from_response(cls, resp, ns) -> list["ConnectionItem"]: + def from_response(cls, resp, ns) -> List["ConnectionItem"]: all_connection_items = list() parsed_response = fromstring(resp) all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) @@ -82,7 +82,7 @@ def from_response(cls, resp, ns) -> list["ConnectionItem"]: return all_connection_items @classmethod - def from_xml_element(cls, parsed_response, ns) -> list["ConnectionItem"]: + def from_xml_element(cls, parsed_response, ns) -> List["ConnectionItem"]: """ @@ -93,7 +93,7 @@ def from_xml_element(cls, parsed_response, ns) -> list["ConnectionItem"]: """ - all_connection_items: list["ConnectionItem"] = list() + all_connection_items: List["ConnectionItem"] = list() all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) for connection_xml in all_connection_xml: diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py index a0c0a9844..246a19e7f 100644 --- a/tableauserverclient/models/custom_view_item.py +++ b/tableauserverclient/models/custom_view_item.py @@ -2,8 +2,7 @@ from defusedxml import ElementTree from defusedxml.ElementTree import fromstring, tostring -from typing import Callable, Optional -from collections.abc import Iterator +from typing import Callable, List, Optional from .exceptions import UnpopulatedPropertyError from .user_item import UserItem @@ -12,14 +11,12 @@ from ..datetime_helpers import parse_datetime -class CustomViewItem: +class CustomViewItem(object): def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None: self._content_url: Optional[str] = None # ? self._created_at: Optional["datetime"] = None self._id: Optional[str] = id self._image: Optional[Callable[[], bytes]] = None - self._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 @@ -38,17 +35,11 @@ def __repr__(self: "CustomViewItem"): owner_info = "" if self._owner: owner_info = " owner='{}'".format(self._owner.name or self._owner.id or "unknown") - return f"" + return "".format(self.id, self.name, view_info, wb_info, owner_info) 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 @@ -64,24 +55,10 @@ def id(self) -> Optional[str]: @property def image(self) -> bytes: if self._image is None: - error = "Custom View item must be populated with its png image first." + error = "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 @@ -127,7 +104,7 @@ def from_response(cls, resp, ns, workbook_id="") -> Optional["CustomViewItem"]: return item[0] @classmethod - def list_from_response(cls, resp, ns, workbook_id="") -> list["CustomViewItem"]: + def list_from_response(cls, resp, ns, workbook_id="") -> List["CustomViewItem"]: return cls.from_xml_element(fromstring(resp), ns, workbook_id) """ @@ -144,7 +121,7 @@ def list_from_response(cls, resp, ns, workbook_id="") -> list["CustomViewItem"]: """ @classmethod - def from_xml_element(cls, parsed_response, ns, workbook_id="") -> list["CustomViewItem"]: + def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["CustomViewItem"]: all_view_items = list() all_view_xml = parsed_response.findall(".//t:customView", namespaces=ns) for custom_view_xml in all_view_xml: diff --git a/tableauserverclient/models/data_acceleration_report_item.py b/tableauserverclient/models/data_acceleration_report_item.py index 3a8883bed..7424e6b95 100644 --- a/tableauserverclient/models/data_acceleration_report_item.py +++ b/tableauserverclient/models/data_acceleration_report_item.py @@ -1,8 +1,8 @@ from defusedxml.ElementTree import fromstring -class DataAccelerationReportItem: - class ComparisonRecord: +class DataAccelerationReportItem(object): + class ComparisonRecord(object): def __init__( self, site, diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index 7285ee609..65be233e3 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Optional +from typing import List, Optional from defusedxml.ElementTree import fromstring @@ -10,7 +10,7 @@ ) -class DataAlertItem: +class DataAlertItem(object): class Frequency: Once = "Once" Frequently = "Frequently" @@ -34,7 +34,7 @@ def __init__(self): self._workbook_name: Optional[str] = None self._project_id: Optional[str] = None self._project_name: Optional[str] = None - self._recipients: Optional[list[str]] = None + self._recipients: Optional[List[str]] = None def __repr__(self) -> str: return " Optional[str]: return self._creatorId @property - def recipients(self) -> list[str]: + def recipients(self) -> List[str]: return self._recipients or list() @property @@ -174,7 +174,7 @@ def _set_values( self._recipients = recipients @classmethod - def from_response(cls, resp, ns) -> list["DataAlertItem"]: + def from_response(cls, resp, ns) -> List["DataAlertItem"]: all_alert_items = list() parsed_response = fromstring(resp) all_alert_xml = parsed_response.findall(".//t:dataAlert", namespaces=ns) diff --git a/tableauserverclient/models/data_freshness_policy_item.py b/tableauserverclient/models/data_freshness_policy_item.py index 6e0cb9001..f567c501c 100644 --- a/tableauserverclient/models/data_freshness_policy_item.py +++ b/tableauserverclient/models/data_freshness_policy_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET -from typing import Optional +from typing import Optional, Union, List from tableauserverclient.models.property_decorators import property_is_enum, property_not_nullable from .interval_item import IntervalItem @@ -50,11 +50,11 @@ class Frequency: Week = "Week" Month = "Month" - def __init__(self, frequency: str, time: str, timezone, interval_item: Optional[list[str]] = None): + def __init__(self, frequency: str, time: str, timezone, interval_item: Optional[List[str]] = None): self.frequency = frequency self.time = time self.timezone = timezone - self.interval_item: Optional[list[str]] = interval_item + self.interval_item: Optional[List[str]] = interval_item def __repr__(self): return ( @@ -62,11 +62,11 @@ def __repr__(self): ).format(**vars(self)) @property - def interval_item(self) -> Optional[list[str]]: + def interval_item(self) -> Optional[List[str]]: return self._interval_item @interval_item.setter - def interval_item(self, value: list[str]): + def interval_item(self, value: List[str]): self._interval_item = value @property @@ -186,7 +186,7 @@ def parse_week_intervals(interval_values): def parse_month_intervals(interval_values): - error = f"Invalid interval value for a monthly frequency: {interval_values}." + error = "Invalid interval value for a monthly frequency: {}.".format(interval_values) # Month interval can have value either only ['LastDay'] or list of dates e.g. ["1", 20", "30"] # First check if the list only have LastDay value. When using LastDay, there shouldn't be diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py index 4d4604461..dfc58e1bb 100644 --- a/tableauserverclient/models/database_item.py +++ b/tableauserverclient/models/database_item.py @@ -10,7 +10,7 @@ ) -class DatabaseItem: +class DatabaseItem(object): class ContentPermissions: LockedToProject = "LockedToDatabase" ManagedByOwner = "ManagedByOwner" @@ -45,7 +45,7 @@ def __init__(self, name, description=None, content_permissions=None): self._tables = None # Not implemented yet def __str__(self): - return f"" + return "".format(self._id, self.name) def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @@ -250,7 +250,7 @@ def _set_tables(self, tables): self._tables = tables def _set_default_permissions(self, permissions, content_type): - attr = f"_default_{content_type}_permissions" + attr = "_default_{content}_permissions".format(content=content_type) setattr( self, attr, diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 1b082c157..e4e71c4a2 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -1,7 +1,7 @@ import copy import datetime import xml.etree.ElementTree as ET -from typing import Optional +from typing import Dict, List, Optional, Set, Tuple from defusedxml.ElementTree import fromstring @@ -18,14 +18,14 @@ from tableauserverclient.models.tag_item import TagItem -class DatasourceItem: +class DatasourceItem(object): class AskDataEnablement: Enabled = "Enabled" Disabled = "Disabled" SiteDefault = "SiteDefault" def __repr__(self): - return "".format( + return "".format( self._id, self.name, self.description or "No Description", @@ -44,7 +44,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self._encrypt_extracts = None self._has_extracts = None self._id: Optional[str] = None - self._initial_tags: set = set() + self._initial_tags: Set = set() self._project_name: Optional[str] = None self._revisions = None self._size: Optional[int] = None @@ -55,7 +55,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self.name = name self.owner_id: Optional[str] = None self.project_id = project_id - self.tags: set[str] = set() + self.tags: Set[str] = set() self._permissions = None self._data_quality_warnings = None @@ -72,14 +72,14 @@ def ask_data_enablement(self, value: Optional[AskDataEnablement]): self._ask_data_enablement = value @property - def connections(self) -> Optional[list[ConnectionItem]]: + def connections(self) -> Optional[List[ConnectionItem]]: if self._connections is None: error = "Datasource item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self) -> Optional[list[PermissionsRule]]: + def permissions(self) -> Optional[List[PermissionsRule]]: if self._permissions is None: error = "Project item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -177,7 +177,7 @@ def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def revisions(self) -> list[RevisionItem]: + def revisions(self) -> List[RevisionItem]: if self._revisions is None: error = "Datasource item must be populated with revisions first." raise UnpopulatedPropertyError(error) @@ -309,7 +309,7 @@ def _set_values( self._size = int(size) @classmethod - def from_response(cls, resp: str, ns: dict) -> list["DatasourceItem"]: + def from_response(cls, resp: str, ns: Dict) -> List["DatasourceItem"]: all_datasource_items = list() parsed_response = fromstring(resp) all_datasource_xml = parsed_response.findall(".//t:datasource", namespaces=ns) @@ -326,7 +326,7 @@ def from_xml(cls, datasource_xml, ns): return datasource_item @staticmethod - def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: + def _parse_element(datasource_xml: ET.Element, ns: Dict) -> Tuple: id_ = datasource_xml.get("id", None) name = datasource_xml.get("name", None) datasource_type = datasource_xml.get("type", None) diff --git a/tableauserverclient/models/dqw_item.py b/tableauserverclient/models/dqw_item.py index fbda9d9f2..ada041481 100644 --- a/tableauserverclient/models/dqw_item.py +++ b/tableauserverclient/models/dqw_item.py @@ -3,7 +3,7 @@ from tableauserverclient.datetime_helpers import parse_datetime -class DQWItem: +class DQWItem(object): class WarningType: WARNING = "WARNING" DEPRECATED = "DEPRECATED" diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index 4fea280f7..caff755e3 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -1,27 +1,28 @@ import logging -from typing import Union from defusedxml.ElementTree import fromstring - from tableauserverclient.models.tableau_types import TableauItem + from tableauserverclient.models.datasource_item import DatasourceItem from tableauserverclient.models.flow_item import FlowItem from tableauserverclient.models.project_item import ProjectItem from tableauserverclient.models.metric_item import MetricItem from tableauserverclient.models.view_item import ViewItem from tableauserverclient.models.workbook_item import WorkbookItem +from typing import Dict, List from tableauserverclient.helpers.logging import logger +from typing import Dict, List, Union -FavoriteType = dict[ +FavoriteType = Dict[ str, - list[TableauItem], + List[TableauItem], ] class FavoriteItem: @classmethod - def from_response(cls, xml: Union[str, bytes], namespace: dict) -> FavoriteType: + def from_response(cls, xml: str, namespace: Dict) -> FavoriteType: favorites: FavoriteType = { "datasources": [], "flows": [], diff --git a/tableauserverclient/models/fileupload_item.py b/tableauserverclient/models/fileupload_item.py index aea4dfe1f..e9bdd25b2 100644 --- a/tableauserverclient/models/fileupload_item.py +++ b/tableauserverclient/models/fileupload_item.py @@ -1,7 +1,7 @@ from defusedxml.ElementTree import fromstring -class FileuploadItem: +class FileuploadItem(object): def __init__(self): self._file_size = None self._upload_session_id = None diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index 9bcad5e89..edce2ec97 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -1,7 +1,7 @@ import copy import datetime import xml.etree.ElementTree as ET -from typing import Optional +from typing import List, Optional, Set from defusedxml.ElementTree import fromstring @@ -14,9 +14,9 @@ from tableauserverclient.models.tag_item import TagItem -class FlowItem: +class FlowItem(object): def __repr__(self): - return " None: self._webpage_url: Optional[str] = None self._created_at: Optional[datetime.datetime] = None self._id: Optional[str] = None - self._initial_tags: set[str] = set() + self._initial_tags: Set[str] = set() self._project_name: Optional[str] = None self._updated_at: Optional[datetime.datetime] = None self.name: Optional[str] = name self.owner_id: Optional[str] = None self.project_id: str = project_id - self.tags: set[str] = set() + self.tags: Set[str] = set() self.description: Optional[str] = None self._connections: Optional[ConnectionItem] = None @@ -170,7 +170,7 @@ def _set_values( self.owner_id = owner_id @classmethod - def from_response(cls, resp, ns) -> list["FlowItem"]: + def from_response(cls, resp, ns) -> List["FlowItem"]: all_flow_items = list() parsed_response = fromstring(resp) all_flow_xml = parsed_response.findall(".//t:flow", namespaces=ns) diff --git a/tableauserverclient/models/flow_run_item.py b/tableauserverclient/models/flow_run_item.py index f2f1d561f..12281f4f8 100644 --- a/tableauserverclient/models/flow_run_item.py +++ b/tableauserverclient/models/flow_run_item.py @@ -1,13 +1,13 @@ import itertools from datetime import datetime -from typing import Optional +from typing import Dict, List, Optional, Type from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -class FlowRunItem: +class FlowRunItem(object): def __init__(self) -> None: self._id: str = "" self._flow_id: Optional[str] = None @@ -71,7 +71,7 @@ def _set_values( self._background_job_id = background_job_id @classmethod - def from_response(cls: type["FlowRunItem"], resp: bytes, ns: Optional[dict]) -> list["FlowRunItem"]: + def from_response(cls: Type["FlowRunItem"], resp: bytes, ns: Optional[Dict]) -> List["FlowRunItem"]: all_flowrun_items = list() parsed_response = fromstring(resp) all_flowrun_xml = itertools.chain( diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 6871f8b16..6c8f7eb01 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -1,4 +1,4 @@ -from typing import Callable, Optional, TYPE_CHECKING +from typing import Callable, List, Optional, TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -11,7 +11,7 @@ from tableauserverclient.server import Pager -class GroupItem: +class GroupItem(object): tag_name: str = "group" class LicenseMode: @@ -27,7 +27,7 @@ def __init__(self, name=None, domain_name=None) -> None: self.domain_name: Optional[str] = domain_name def __repr__(self): - return f"{self.__class__.__name__}({self.__dict__!r})" + return "{}({!r})".format(self.__class__.__name__, self.__dict__) @property def domain_name(self) -> Optional[str]: @@ -79,7 +79,7 @@ def _set_users(self, users: Callable[..., "Pager"]) -> None: self._users = users @classmethod - def from_response(cls, resp, ns) -> list["GroupItem"]: + def from_response(cls, resp, ns) -> List["GroupItem"]: all_group_items = list() parsed_response = fromstring(resp) all_group_xml = parsed_response.findall(".//t:group", namespaces=ns) diff --git a/tableauserverclient/models/groupset_item.py b/tableauserverclient/models/groupset_item.py index aa653a79e..ffb57adf5 100644 --- a/tableauserverclient/models/groupset_item.py +++ b/tableauserverclient/models/groupset_item.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Dict, List, Optional import xml.etree.ElementTree as ET from defusedxml.ElementTree import fromstring @@ -13,7 +13,7 @@ class GroupSetItem: def __init__(self, name: Optional[str] = None) -> None: self.name = name self.id: Optional[str] = None - self.groups: list["GroupItem"] = [] + self.groups: List["GroupItem"] = [] self.group_count: int = 0 def __str__(self) -> str: @@ -25,13 +25,13 @@ def __repr__(self) -> str: return self.__str__() @classmethod - def from_response(cls, response: bytes, ns: dict[str, str]) -> list["GroupSetItem"]: + def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["GroupSetItem"]: parsed_response = fromstring(response) all_groupset_xml = parsed_response.findall(".//t:groupSet", namespaces=ns) return [cls.from_xml(xml, ns) for xml in all_groupset_xml] @classmethod - def from_xml(cls, groupset_xml: ET.Element, ns: dict[str, str]) -> "GroupSetItem": + def from_xml(cls, groupset_xml: ET.Element, ns: Dict[str, str]) -> "GroupSetItem": def get_group(group_xml: ET.Element) -> GroupItem: group_item = GroupItem() group_item._id = group_xml.get("id") diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index d7cf891cc..444674e19 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -1,7 +1,7 @@ from .property_decorators import property_is_valid_time, property_not_nullable -class IntervalItem: +class IntervalItem(object): class Frequency: Hourly = "Hourly" Daily = "Daily" @@ -25,7 +25,7 @@ class Day: LastDay = "LastDay" -class HourlyInterval: +class HourlyInterval(object): def __init__(self, start_time, end_time, interval_value): self.start_time = start_time self.end_time = end_time @@ -73,12 +73,12 @@ def interval(self, intervals): for interval in intervals: # if an hourly interval is a string, then it is a weekDay interval if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): - error = f"Invalid weekDay interval {interval}" + error = "Invalid weekDay interval {}".format(interval) raise ValueError(error) # if an hourly interval is a number, it is an hours or minutes interval if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS: - error = f"Invalid interval {interval} not in {str(VALID_INTERVALS)}" + error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) raise ValueError(error) self._interval = intervals @@ -108,7 +108,7 @@ def _interval_type_pairs(self): return interval_type_pairs -class DailyInterval: +class DailyInterval(object): def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -141,12 +141,12 @@ def interval(self, intervals): for interval in intervals: # if an hourly interval is a string, then it is a weekDay interval if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): - error = f"Invalid weekDay interval {interval}" + error = "Invalid weekDay interval {}".format(interval) raise ValueError(error) # if an hourly interval is a number, it is an hours or minutes interval if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS: - error = f"Invalid interval {interval} not in {str(VALID_INTERVALS)}" + error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) raise ValueError(error) self._interval = intervals @@ -176,7 +176,7 @@ def _interval_type_pairs(self): return interval_type_pairs -class WeeklyInterval: +class WeeklyInterval(object): def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -213,7 +213,7 @@ def _interval_type_pairs(self): return [(IntervalItem.Occurrence.WeekDay, day) for day in self.interval] -class MonthlyInterval: +class MonthlyInterval(object): def __init__(self, start_time, interval_value): self.start_time = start_time diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index cc7cd5811..155ce668b 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,5 +1,5 @@ import datetime -from typing import Optional +from typing import List, Optional from defusedxml.ElementTree import fromstring @@ -7,7 +7,7 @@ from tableauserverclient.models.flow_run_item import FlowRunItem -class JobItem: +class JobItem(object): class FinishCode: """ Status codes as documented on @@ -27,7 +27,7 @@ def __init__( started_at: Optional[datetime.datetime] = None, completed_at: Optional[datetime.datetime] = None, finish_code: int = 0, - notes: Optional[list[str]] = None, + notes: Optional[List[str]] = None, mode: Optional[str] = None, workbook_id: Optional[str] = None, datasource_id: Optional[str] = None, @@ -43,7 +43,7 @@ def __init__( self._started_at = started_at self._completed_at = completed_at self._finish_code = finish_code - self._notes: list[str] = notes or [] + self._notes: List[str] = notes or [] self._mode = mode self._workbook_id = workbook_id self._datasource_id = datasource_id @@ -81,7 +81,7 @@ def finish_code(self) -> int: return self._finish_code @property - def notes(self) -> list[str]: + def notes(self) -> List[str]: return self._notes @property @@ -139,7 +139,7 @@ def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @classmethod - def from_response(cls, xml, ns) -> list["JobItem"]: + def from_response(cls, xml, ns) -> List["JobItem"]: parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:job", namespaces=ns) @@ -191,7 +191,7 @@ def _parse_element(cls, element, ns): ) -class BackgroundJobItem: +class BackgroundJobItem(object): class Status: Pending: str = "Pending" InProgress: str = "InProgress" @@ -270,7 +270,7 @@ def priority(self) -> int: return self._priority @classmethod - def from_response(cls, xml, ns) -> list["BackgroundJobItem"]: + def from_response(cls, xml, ns) -> List["BackgroundJobItem"]: parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:backgroundJob", namespaces=ns) return [cls._parse_element(x, ns) for x in all_tasks_xml] diff --git a/tableauserverclient/models/linked_tasks_item.py b/tableauserverclient/models/linked_tasks_item.py index 14a0e4978..ae9b60425 100644 --- a/tableauserverclient/models/linked_tasks_item.py +++ b/tableauserverclient/models/linked_tasks_item.py @@ -1,5 +1,5 @@ import datetime as dt -from typing import Optional +from typing import List, Optional from defusedxml.ElementTree import fromstring @@ -14,7 +14,7 @@ def __init__(self) -> None: self.schedule: Optional[ScheduleItem] = None @classmethod - def from_response(cls, resp: bytes, namespace) -> list["LinkedTaskItem"]: + def from_response(cls, resp: bytes, namespace) -> List["LinkedTaskItem"]: parsed_response = fromstring(resp) return [ cls._parse_element(x, namespace) @@ -35,10 +35,10 @@ def __init__(self) -> None: self.id: Optional[str] = None self.step_number: Optional[int] = None self.stop_downstream_on_failure: Optional[bool] = None - self.task_details: list[LinkedTaskFlowRunItem] = [] + self.task_details: List[LinkedTaskFlowRunItem] = [] @classmethod - def from_task_xml(cls, xml, namespace) -> list["LinkedTaskStepItem"]: + def from_task_xml(cls, xml, namespace) -> List["LinkedTaskStepItem"]: return [cls._parse_element(x, namespace) for x in xml.findall(".//t:linkedTaskSteps[@id]", namespace)] @classmethod @@ -61,7 +61,7 @@ def __init__(self) -> None: self.flow_name: Optional[str] = None @classmethod - def _parse_element(cls, xml, namespace) -> list["LinkedTaskFlowRunItem"]: + def _parse_element(cls, xml, namespace) -> List["LinkedTaskFlowRunItem"]: all_tasks = [] for flow_run in xml.findall(".//t:flowRun[@id]", namespace): task = cls() diff --git a/tableauserverclient/models/metric_item.py b/tableauserverclient/models/metric_item.py index 432fd861a..d8ba8e825 100644 --- a/tableauserverclient/models/metric_item.py +++ b/tableauserverclient/models/metric_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from datetime import datetime -from typing import Optional +from typing import List, Optional, Set from tableauserverclient.datetime_helpers import parse_datetime from .property_decorators import property_is_boolean, property_is_datetime @@ -8,7 +8,7 @@ from .permissions_item import Permission -class MetricItem: +class MetricItem(object): def __init__(self, name: Optional[str] = None): self._id: Optional[str] = None self._name: Optional[str] = name @@ -21,8 +21,8 @@ def __init__(self, name: Optional[str] = None): self._project_name: Optional[str] = None self._owner_id: Optional[str] = None self._view_id: Optional[str] = None - self._initial_tags: set[str] = set() - self.tags: set[str] = set() + self._initial_tags: Set[str] = set() + self.tags: Set[str] = set() self._permissions: Optional[Permission] = None @property @@ -126,7 +126,7 @@ def from_response( cls, resp: bytes, ns, - ) -> list["MetricItem"]: + ) -> List["MetricItem"]: all_metric_items = list() parsed_response = ET.fromstring(resp) all_metric_xml = parsed_response.findall(".//t:metric", namespaces=ns) diff --git a/tableauserverclient/models/pagination_item.py b/tableauserverclient/models/pagination_item.py index f30519be5..8cebd1c86 100644 --- a/tableauserverclient/models/pagination_item.py +++ b/tableauserverclient/models/pagination_item.py @@ -1,7 +1,7 @@ from defusedxml.ElementTree import fromstring -class PaginationItem: +class PaginationItem(object): def __init__(self): self._page_number = None self._page_size = None diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index bb3487279..26f4ee7e8 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -1,5 +1,5 @@ import xml.etree.ElementTree as ET -from typing import Optional +from typing import Dict, List, Optional from defusedxml.ElementTree import fromstring @@ -36,25 +36,23 @@ 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 "" class PermissionsRule: - def __init__(self, grantee: ResourceReference, capabilities: dict[str, str]) -> None: + def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> None: self.grantee = grantee self.capabilities = capabilities def __repr__(self): - return f"" + return "".format(self.grantee, self.capabilities) def __eq__(self, other: object) -> bool: if not hasattr(other, "grantee") or not hasattr(other, "capabilities"): @@ -68,7 +66,7 @@ def __and__(self, other: "PermissionsRule") -> "PermissionsRule": if self.capabilities == other.capabilities: return self - capabilities = {*self.capabilities.keys(), *other.capabilities.keys()} + capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) new_capabilities = {} for capability in capabilities: if (self.capabilities.get(capability), other.capabilities.get(capability)) == ( @@ -88,7 +86,7 @@ def __or__(self, other: "PermissionsRule") -> "PermissionsRule": if self.capabilities == other.capabilities: return self - capabilities = {*self.capabilities.keys(), *other.capabilities.keys()} + capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) new_capabilities = {} for capability in capabilities: if Permission.Mode.Allow in (self.capabilities.get(capability), other.capabilities.get(capability)): @@ -102,14 +100,14 @@ def __or__(self, other: "PermissionsRule") -> "PermissionsRule": return PermissionsRule(self.grantee, new_capabilities) @classmethod - def from_response(cls, resp, ns=None) -> list["PermissionsRule"]: + def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: parsed_response = fromstring(resp) rules = [] permissions_rules_list_xml = parsed_response.findall(".//t:granteeCapabilities", namespaces=ns) for grantee_capability_xml in permissions_rules_list_xml: - capability_dict: dict[str, str] = {} + capability_dict: Dict[str, str] = {} grantee = PermissionsRule._parse_grantee_element(grantee_capability_xml, ns) @@ -118,7 +116,7 @@ def from_response(cls, resp, ns=None) -> list["PermissionsRule"]: mode = capability_xml.get("mode") if name is None or mode is None: - logger.error(f"Capability was not valid: {capability_xml}") + logger.error("Capability was not valid: {}".format(capability_xml)) raise UnpopulatedPropertyError() else: capability_dict[name] = mode @@ -129,7 +127,7 @@ def from_response(cls, resp, ns=None) -> list["PermissionsRule"]: return rules @staticmethod - def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[dict[str, str]]) -> ResourceReference: + def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict[str, str]]) -> ResourceReference: """Use Xpath magic and some string splitting to get the right object type from the xml""" # Get the first element in the tree with an 'id' attribute @@ -148,6 +146,6 @@ def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[dict elif grantee_type == "groupSet": grantee = GroupSetItem.as_reference(grantee_id) else: - raise UnknownGranteeTypeError(f"No support for grantee type of {grantee_type}") + raise UnknownGranteeTypeError("No support for grantee type of {}".format(grantee_type)) return grantee diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 48f27c60c..9fb382885 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,6 +1,6 @@ import logging import xml.etree.ElementTree as ET -from typing import Optional +from typing import List, Optional from defusedxml.ElementTree import fromstring @@ -8,16 +8,14 @@ from tableauserverclient.models.property_decorators import property_is_enum, property_not_empty -class ProjectItem: - ERROR_MSG = "Project item must be populated with permissions first." - +class ProjectItem(object): class ContentPermissions: LockedToProject: str = "LockedToProject" ManagedByOwner: str = "ManagedByOwner" LockedToProjectWithoutNested: str = "LockedToProjectWithoutNested" def __repr__(self): - return "".format( + return "".format( self._id, self.name, self.parent_id or "None (Top level)", self.content_permissions or "Not Set" ) @@ -45,9 +43,6 @@ 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): @@ -61,63 +56,52 @@ def content_permissions(self, value: Optional[str]) -> None: @property def permissions(self): if self._permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) return self._permissions() @property def default_datasource_permissions(self): if self._default_datasource_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) return self._default_datasource_permissions() @property def default_workbook_permissions(self): if self._default_workbook_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) return self._default_workbook_permissions() @property def default_flow_permissions(self): if self._default_flow_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) return self._default_flow_permissions() @property def default_lens_permissions(self): if self._default_lens_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) return self._default_lens_permissions() @property def default_datarole_permissions(self): if self._default_datarole_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) return self._default_datarole_permissions() @property def default_metric_permissions(self): if self._default_metric_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) 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 @@ -174,7 +158,7 @@ def _set_permissions(self, permissions): self._permissions = permissions def _set_default_permissions(self, permissions, content_type): - attr = f"_default_{content_type}_permissions" + attr = "_default_{content}_permissions".format(content=content_type) setattr( self, attr, @@ -182,7 +166,7 @@ def _set_default_permissions(self, permissions, content_type): ) @classmethod - def from_response(cls, resp, ns) -> list["ProjectItem"]: + def from_response(cls, resp, ns) -> List["ProjectItem"]: all_project_items = list() parsed_response = fromstring(resp) all_project_xml = parsed_response.findall(".//t:project", namespaces=ns) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 5048b3498..ce31b1428 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,8 +1,7 @@ import datetime import re from functools import wraps -from typing import Any, Optional -from collections.abc import Container +from typing import Any, Container, Optional, Tuple from tableauserverclient.datetime_helpers import parse_datetime @@ -12,7 +11,7 @@ def property_type_decorator(func): @wraps(func) def wrapper(self, value): if value is not None and not hasattr(enum_type, value): - error = f"Invalid value: {value}. {func.__name__} must be of type {enum_type.__name__}." + error = "Invalid value: {0}. {1} must be of type {2}.".format(value, func.__name__, enum_type.__name__) raise ValueError(error) return func(self, value) @@ -25,7 +24,7 @@ def property_is_boolean(func): @wraps(func) def wrapper(self, value): if not isinstance(value, bool): - error = f"Boolean expected for {func.__name__} flag." + error = "Boolean expected for {0} flag.".format(func.__name__) raise ValueError(error) return func(self, value) @@ -36,7 +35,7 @@ def property_not_nullable(func): @wraps(func) def wrapper(self, value): if value is None: - error = f"{func.__name__} must be defined." + error = "{0} must be defined.".format(func.__name__) raise ValueError(error) return func(self, value) @@ -47,7 +46,7 @@ def property_not_empty(func): @wraps(func) def wrapper(self, value): if not value: - error = f"{func.__name__} must not be empty." + error = "{0} must not be empty.".format(func.__name__) raise ValueError(error) return func(self, value) @@ -67,7 +66,7 @@ def wrapper(self, value): return wrapper -def property_is_int(range: tuple[int, int], allowed: Optional[Container[Any]] = None): +def property_is_int(range: Tuple[int, int], allowed: Optional[Container[Any]] = None): """Takes a range of ints and a list of exemptions to check against when setting a property on a model. The range is a tuple of (min, max) and the allowed list (empty by default) allows values outside that range. @@ -82,7 +81,7 @@ def property_is_int(range: tuple[int, int], allowed: Optional[Container[Any]] = def property_type_decorator(func): @wraps(func) def wrapper(self, value): - error = f"Invalid property defined: '{value}'. Integer value expected." + error = "Invalid property defined: '{}'. Integer value expected.".format(value) if range is None: if isinstance(value, int): @@ -134,7 +133,7 @@ def wrapper(self, value): return func(self, value) if not isinstance(value, str): raise ValueError( - f"Cannot convert {value.__class__.__name__} into a datetime, cannot update {func.__name__}" + "Cannot convert {} into a datetime, cannot update {}".format(value.__class__.__name__, func.__name__) ) dt = parse_datetime(value) @@ -147,11 +146,11 @@ def property_is_data_acceleration_config(func): @wraps(func) def wrapper(self, value): if not isinstance(value, dict): - raise ValueError(f"{value.__class__.__name__} is not type 'dict', cannot update {func.__name__})") + raise ValueError("{} is not type 'dict', cannot update {})".format(value.__class__.__name__, func.__name__)) if len(value) < 2 or not all(attr in value.keys() for attr in ("acceleration_enabled", "accelerate_now")): - error = f"{func.__name__} should have 2 keys " + error = "{} should have 2 keys ".format(func.__name__) error += "'acceleration_enabled' and 'accelerate_now'" - error += f"instead you have {value.keys()}" + error += "instead you have {}".format(value.keys()) raise ValueError(error) return func(self, value) diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py index 4c1fff564..710548fcc 100644 --- a/tableauserverclient/models/reference_item.py +++ b/tableauserverclient/models/reference_item.py @@ -1,10 +1,10 @@ -class ResourceReference: +class ResourceReference(object): def __init__(self, id_, tag_name): self.id = id_ self.tag_name = tag_name def __str__(self): - return f"" + return "".format(self._id, self._tag_name) __repr__ = __str__ diff --git a/tableauserverclient/models/revision_item.py b/tableauserverclient/models/revision_item.py index 1b4cc6249..a0e6a1bd5 100644 --- a/tableauserverclient/models/revision_item.py +++ b/tableauserverclient/models/revision_item.py @@ -1,12 +1,12 @@ from datetime import datetime -from typing import Optional +from typing import List, Optional from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -class RevisionItem: +class RevisionItem(object): def __init__(self): self._resource_id: Optional[str] = None self._resource_name: Optional[str] = None @@ -56,7 +56,7 @@ def __repr__(self): ) @classmethod - def from_response(cls, resp: bytes, ns, resource_item) -> list["RevisionItem"]: + def from_response(cls, resp: bytes, ns, resource_item) -> List["RevisionItem"]: all_revision_items = list() parsed_response = fromstring(resp) all_revision_xml = parsed_response.findall(".//t:revision", namespaces=ns) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index e39042058..e416643ba 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -19,7 +19,7 @@ Interval = Union[HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval] -class ScheduleItem: +class ScheduleItem(object): class Type: Extract = "Extract" Flow = "Flow" @@ -336,7 +336,7 @@ def parse_add_to_schedule_response(response, ns): all_task_xml = parsed_response.findall(".//t:task", namespaces=ns) error = ( - f"Status {response.status_code}: {response.reason}" + "Status {}: {}".format(response.status_code, response.reason) if response.status_code < 200 or response.status_code >= 300 else None ) diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index b13f26740..57fc51af9 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -6,29 +6,7 @@ from tableauserverclient.helpers.logging import logger -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. - """ - +class ServerInfoItem(object): def __init__(self, product_version, build_number, rest_api_version): self._product_version = product_version self._build_number = build_number @@ -62,11 +40,13 @@ def from_response(cls, resp, ns): try: parsed_response = fromstring(resp) except xml.etree.ElementTree.ParseError as error: - logger.exception(f"Unexpected response for ServerInfo: {resp}") + logger.info("Unexpected response for ServerInfo: {}".format(resp)) + logger.info(error) return cls("Unknown", "Unknown", "Unknown") except Exception as error: - logger.exception(f"Unexpected response for ServerInfo: {resp}") - raise error + logger.info("Unexpected response for ServerInfo: {}".format(resp)) + logger.info(error) + return cls("Unknown", "Unknown", "Unknown") product_version_tag = parsed_response.find(".//t:productVersion", namespaces=ns) rest_api_version_tag = parsed_response.find(".//t:restApiVersion", namespaces=ns) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index e4e146f9c..b651e5773 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -14,79 +14,13 @@ VALID_CONTENT_URL_RE = r"^[a-zA-Z0-9_\-]*$" -from typing import Optional, Union, TYPE_CHECKING +from typing import List, Optional, Union, TYPE_CHECKING if TYPE_CHECKING: from tableauserverclient.server import Server -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). - - """ - +class SiteItem(object): _user_quota: Optional[int] = None _tier_creator_capacity: Optional[int] = None _tier_explorer_capacity: Optional[int] = None @@ -939,7 +873,7 @@ def _set_values( self.auto_suspend_refresh_inactivity_window = auto_suspend_refresh_inactivity_window @classmethod - def from_response(cls, resp, ns) -> list["SiteItem"]: + def from_response(cls, resp, ns) -> List["SiteItem"]: all_site_items = list() parsed_response = fromstring(resp) all_site_xml = parsed_response.findall(".//t:site", namespaces=ns) diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index 61c75e2d6..e96fcc448 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 from defusedxml.ElementTree import fromstring @@ -10,7 +10,7 @@ from .target import Target -class SubscriptionItem: +class SubscriptionItem(object): def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None: self._id = None self.attach_image = True @@ -79,7 +79,7 @@ def suspended(self, value: bool) -> None: self._suspended = value @classmethod - def from_response(cls: type, xml: bytes, ns) -> list["SubscriptionItem"]: + def from_response(cls: Type, xml: bytes, ns) -> List["SubscriptionItem"]: parsed_response = fromstring(xml) all_subscriptions_xml = parsed_response.findall(".//t:subscription", namespaces=ns) diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index 0afdd4df3..f9df8a8f3 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -4,7 +4,7 @@ from .property_decorators import property_not_empty, property_is_boolean -class TableItem: +class TableItem(object): def __init__(self, name, description=None): self._id = None self.description = description diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 7d7981433..10cf58723 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -1,5 +1,5 @@ import abc -from typing import Optional +from typing import Dict, Optional class Credentials(abc.ABC): @@ -9,7 +9,7 @@ def __init__(self, site_id: Optional[str] = None, user_id_to_impersonate: Option @property @abc.abstractmethod - def credentials(self) -> dict[str, str]: + def credentials(self) -> Dict[str, str]: credentials = ( "Credentials can be username/password, Personal Access Token, or JWT" "This method returns values to set as an attribute on the credentials element of the request" @@ -32,43 +32,6 @@ 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: @@ -79,7 +42,7 @@ def __init__( self.username = username @property - def credentials(self) -> dict[str, str]: + def credentials(self) -> Dict[str, str]: return {"name": self.username, "password": self.password} def __repr__(self): @@ -92,43 +55,6 @@ 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, @@ -143,7 +69,7 @@ def __init__( self.personal_access_token = personal_access_token @property - def credentials(self) -> dict[str, str]: + def credentials(self) -> Dict[str, str]: return { "personalAccessTokenName": self.token_name, "personalAccessTokenSecret": self.personal_access_token, @@ -162,42 +88,6 @@ 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") @@ -205,7 +95,7 @@ def __init__(self, jwt: str, site_id: Optional[str] = None, user_id_to_impersona self.jwt = jwt @property - def credentials(self) -> dict[str, str]: + def credentials(self) -> Dict[str, str]: return {"jwt": self.jwt} def __repr__(self): diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index 01ee3d3a9..bac072076 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -28,8 +28,8 @@ class Resource: TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem, VirtualConnectionItem] -def plural_type(content_type: Union[Resource, str]) -> str: +def plural_type(content_type: Resource) -> str: if content_type == Resource.Lens: return "lenses" else: - return f"{content_type}s" + return "{}s".format(content_type) diff --git a/tableauserverclient/models/tag_item.py b/tableauserverclient/models/tag_item.py index cde755f05..afa0a0762 100644 --- a/tableauserverclient/models/tag_item.py +++ b/tableauserverclient/models/tag_item.py @@ -1,15 +1,16 @@ import xml.etree.ElementTree as ET +from typing import Set from defusedxml.ElementTree import fromstring -class TagItem: +class TagItem(object): @classmethod - def from_response(cls, resp: bytes, ns) -> set[str]: + def from_response(cls, resp: bytes, ns) -> Set[str]: return cls.from_xml_element(fromstring(resp), ns) @classmethod - def from_xml_element(cls, parsed_response: ET.Element, ns) -> set[str]: + def from_xml_element(cls, parsed_response: ET.Element, ns) -> Set[str]: all_tags = set() tag_elem = parsed_response.findall(".//t:tag", namespaces=ns) for tag_xml in tag_elem: diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index fa6f782ba..01cfcfb11 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Optional +from typing import List, Optional from defusedxml.ElementTree import fromstring @@ -8,7 +8,7 @@ from tableauserverclient.models.target import Target -class TaskItem: +class TaskItem(object): class Type: ExtractRefresh = "extractRefresh" DataAcceleration = "dataAcceleration" @@ -48,9 +48,9 @@ def __repr__(self) -> str: ) @classmethod - def from_response(cls, xml, ns, task_type=Type.ExtractRefresh) -> list["TaskItem"]: + def from_response(cls, xml, ns, task_type=Type.ExtractRefresh) -> List["TaskItem"]: parsed_response = fromstring(xml) - all_tasks_xml = parsed_response.findall(f".//t:task/t:{task_type}", namespaces=ns) + all_tasks_xml = parsed_response.findall(".//t:task/t:{}".format(task_type), namespaces=ns) all_tasks = (TaskItem._parse_element(x, ns) for x in all_tasks_xml) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 365e44c1d..fe659575a 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -2,7 +2,7 @@ import xml.etree.ElementTree as ET from datetime import datetime from enum import IntEnum -from typing import Optional, TYPE_CHECKING +from typing import Dict, List, Optional, TYPE_CHECKING, Tuple from defusedxml.ElementTree import fromstring @@ -18,35 +18,10 @@ from tableauserverclient.server import Pager -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. - """ - +class UserItem(object): 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" @@ -68,11 +43,6 @@ 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" @@ -87,7 +57,7 @@ def __init__( self._id: Optional[str] = None self._last_login: Optional[datetime] = None self._workbooks = None - self._favorites: Optional[dict[str, list]] = None + self._favorites: Optional[Dict[str, List]] = None self._groups = None self.email: Optional[str] = None self.fullname: Optional[str] = None @@ -99,7 +69,7 @@ def __init__( def __str__(self) -> str: str_site_role = self.site_role or "None" - return f"" + return "".format(self.id, self.name, str_site_role) def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @@ -171,7 +141,7 @@ def workbooks(self) -> "Pager": return self._workbooks() @property - def favorites(self) -> dict[str, list]: + def favorites(self) -> Dict[str, List]: if self._favorites is None: error = "User item must be populated with favorites first." raise UnpopulatedPropertyError(error) @@ -240,12 +210,12 @@ def _set_values( self._domain_name = domain_name @classmethod - def from_response(cls, resp, ns) -> list["UserItem"]: + def from_response(cls, resp, ns) -> List["UserItem"]: element_name = ".//t:user" return cls._parse_xml(element_name, resp, ns) @classmethod - def from_response_as_owner(cls, resp, ns) -> list["UserItem"]: + def from_response_as_owner(cls, resp, ns) -> List["UserItem"]: element_name = ".//t:owner" return cls._parse_xml(element_name, resp, ns) @@ -313,7 +283,7 @@ def _parse_element(user_xml, ns): domain_name, ) - class CSVImport: + class CSVImport(object): """ This class includes hardcoded options and logic for the CSV file format defined for user import https://help.tableau.com/current/server/en-us/users_import.htm @@ -338,7 +308,7 @@ def create_user_from_line(line: str): if line is None or line is False or line == "\n" or line == "": return None line = line.strip().lower() - values: list[str] = list(map(str.strip, line.split(","))) + values: List[str] = list(map(str.strip, line.split(","))) user = UserItem(values[UserItem.CSVImport.ColumnType.USERNAME]) if len(values) > 1: if len(values) > UserItem.CSVImport.ColumnType.MAX: @@ -367,7 +337,7 @@ def create_user_from_line(line: str): # Read through an entire CSV file meant for user import # Return the number of valid lines and a list of all the invalid lines @staticmethod - def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, list[str]]: + def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> Tuple[int, List[str]]: num_valid_lines = 0 invalid_lines = [] csv_file.seek(0) # set to start of file in case it has been read earlier @@ -375,11 +345,11 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, l while line and line != "": try: # do not print passwords - logger.info(f"Reading user {line[:4]}") + logger.info("Reading user {}".format(line[:4])) UserItem.CSVImport._validate_import_line_or_throw(line, logger) num_valid_lines += 1 except Exception as exc: - logger.info(f"Error parsing {line[:4]}: {exc}") + logger.info("Error parsing {}: {}".format(line[:4], exc)) invalid_lines.append(line) line = csv_file.readline() return num_valid_lines, invalid_lines @@ -388,7 +358,7 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, l # Iterate through each field and validate the given value against hardcoded constraints @staticmethod def _validate_import_line_or_throw(incoming, logger) -> None: - _valid_attributes: list[list[str]] = [ + _valid_attributes: List[List[str]] = [ [], [], [], @@ -403,23 +373,23 @@ def _validate_import_line_or_throw(incoming, logger) -> None: if len(line) > UserItem.CSVImport.ColumnType.MAX: raise AttributeError("Too many attributes in line") username = line[UserItem.CSVImport.ColumnType.USERNAME.value] - logger.debug(f"> details - {username}") + logger.debug("> details - {}".format(username)) UserItem.validate_username_or_throw(username) for i in range(1, len(line)): - logger.debug(f"column {UserItem.CSVImport.ColumnType(i).name}: {line[i]}") + logger.debug("column {}: {}".format(UserItem.CSVImport.ColumnType(i).name, line[i])) UserItem.CSVImport._validate_attribute_value( line[i], _valid_attributes[i], UserItem.CSVImport.ColumnType(i) ) # Given a restricted set of possible values, confirm the item is in that set @staticmethod - def _validate_attribute_value(item: str, possible_values: list[str], column_type) -> None: + def _validate_attribute_value(item: str, possible_values: List[str], column_type) -> None: if item is None or item == "": # value can be empty for any column except user, which is checked elsewhere return if item in possible_values or possible_values == []: return - raise AttributeError(f"Invalid value {item} for {column_type}") + raise AttributeError("Invalid value {} for {}".format(item, column_type)) # https://help.tableau.com/current/server/en-us/csvguidelines.htm#settings_and_site_roles # This logic is hardcoded to match the existing rules for import csv files diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index dc5f37a48..a26e364a3 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,8 +1,7 @@ import copy from datetime import datetime from requests import Response -from typing import Callable, Optional -from collections.abc import Iterator +from typing import Callable, Iterator, List, Optional, Set from defusedxml.ElementTree import fromstring @@ -12,13 +11,13 @@ from .tag_item import TagItem -class ViewItem: +class ViewItem(object): def __init__(self) -> None: self._content_url: Optional[str] = None self._created_at: Optional[datetime] = None self._id: Optional[str] = None self._image: Optional[Callable[[], bytes]] = None - self._initial_tags: set[str] = set() + self._initial_tags: Set[str] = set() self._name: Optional[str] = None self._owner_id: Optional[str] = None self._preview_image: Optional[Callable[[], bytes]] = None @@ -30,15 +29,15 @@ def __init__(self) -> None: self._sheet_type: Optional[str] = None self._updated_at: Optional[datetime] = None self._workbook_id: Optional[str] = None - self._permissions: Optional[Callable[[], list[PermissionsRule]]] = None - self.tags: set[str] = set() + self._permissions: Optional[Callable[[], List[PermissionsRule]]] = None + self.tags: Set[str] = set() self._data_acceleration_config = { "acceleration_enabled": None, "acceleration_status": None, } def __str__(self): - return "".format( + return "".format( self._id, self.name, self.content_url, self.project_id ) @@ -147,21 +146,21 @@ def data_acceleration_config(self, value): self._data_acceleration_config = value @property - def permissions(self) -> list[PermissionsRule]: + def permissions(self) -> List[PermissionsRule]: if self._permissions is None: error = "View item must be populated with permissions first." raise UnpopulatedPropertyError(error) return self._permissions() - def _set_permissions(self, permissions: Callable[[], list[PermissionsRule]]) -> None: + def _set_permissions(self, permissions: Callable[[], List[PermissionsRule]]) -> None: self._permissions = permissions @classmethod - def from_response(cls, resp: "Response", ns, workbook_id="") -> list["ViewItem"]: + def from_response(cls, resp: "Response", ns, workbook_id="") -> List["ViewItem"]: return cls.from_xml_element(fromstring(resp), ns, workbook_id) @classmethod - def from_xml_element(cls, parsed_response, ns, workbook_id="") -> list["ViewItem"]: + def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["ViewItem"]: all_view_items = list() all_view_xml = parsed_response.findall(".//t:view", namespaces=ns) for view_xml in all_view_xml: diff --git a/tableauserverclient/models/virtual_connection_item.py b/tableauserverclient/models/virtual_connection_item.py index e9e22be1e..76a3b5dea 100644 --- a/tableauserverclient/models/virtual_connection_item.py +++ b/tableauserverclient/models/virtual_connection_item.py @@ -1,7 +1,6 @@ import datetime as dt import json -from typing import Callable, Optional -from collections.abc import Iterable +from typing import Callable, Dict, Iterable, List, Optional from xml.etree.ElementTree import Element from defusedxml.ElementTree import fromstring @@ -24,7 +23,7 @@ def __init__(self, name: str) -> None: self._connections: Optional[Callable[[], Iterable[ConnectionItem]]] = None self.project_id: Optional[str] = None self.owner_id: Optional[str] = None - self.content: Optional[dict[str, dict]] = None + self.content: Optional[Dict[str, dict]] = None self.certification_note: Optional[str] = None def __str__(self) -> str: @@ -41,7 +40,7 @@ def id(self) -> Optional[str]: return self._id @property - def permissions(self) -> list[PermissionsRule]: + def permissions(self) -> List[PermissionsRule]: if self._permissions is None: error = "Workbook item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -54,12 +53,12 @@ def connections(self) -> Iterable[ConnectionItem]: return self._connections() @classmethod - def from_response(cls, response: bytes, ns: dict[str, str]) -> list["VirtualConnectionItem"]: + def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["VirtualConnectionItem"]: parsed_response = fromstring(response) return [cls.from_xml(xml, ns) for xml in parsed_response.findall(".//t:virtualConnection[@name]", ns)] @classmethod - def from_xml(cls, xml: Element, ns: dict[str, str]) -> "VirtualConnectionItem": + def from_xml(cls, xml: Element, ns: Dict[str, str]) -> "VirtualConnectionItem": v_conn = cls(xml.get("name", "")) v_conn._id = xml.get("id", None) v_conn.webpage_url = xml.get("webpageUrl", None) diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py index 98d821fb4..e4d5e4aa0 100644 --- a/tableauserverclient/models/webhook_item.py +++ b/tableauserverclient/models/webhook_item.py @@ -1,6 +1,6 @@ import re import xml.etree.ElementTree as ET -from typing import Optional +from typing import List, Optional, Tuple, Type from defusedxml.ElementTree import fromstring @@ -13,7 +13,7 @@ def _parse_event(events): return NAMESPACE_RE.sub("", event.tag) -class WebhookItem: +class WebhookItem(object): def __init__(self): self._id: Optional[str] = None self.name: Optional[str] = None @@ -45,10 +45,10 @@ def event(self) -> Optional[str]: @event.setter def event(self, value: str) -> None: - self._event = f"webhook-source-event-{value}" + self._event = "webhook-source-event-{}".format(value) @classmethod - def from_response(cls: type["WebhookItem"], resp: bytes, ns) -> list["WebhookItem"]: + def from_response(cls: Type["WebhookItem"], resp: bytes, ns) -> List["WebhookItem"]: all_webhooks_items = list() parsed_response = fromstring(resp) all_webhooks_xml = parsed_response.findall(".//t:webhook", namespaces=ns) @@ -61,7 +61,7 @@ def from_response(cls: type["WebhookItem"], resp: bytes, ns) -> list["WebhookIte return all_webhooks_items @staticmethod - def _parse_element(webhook_xml: ET.Element, ns) -> tuple: + def _parse_element(webhook_xml: ET.Element, ns) -> Tuple: id = webhook_xml.get("id", None) name = webhook_xml.get("name", None) @@ -82,4 +82,4 @@ def _parse_element(webhook_xml: ET.Element, ns) -> tuple: return id, name, url, event, owner_id def __repr__(self) -> str: - return f"" + return "".format(self.id, self.name, self.url, self.event) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 776d041e3..58fd2a9a9 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -2,7 +2,7 @@ import datetime import uuid import xml.etree.ElementTree as ET -from typing import Callable, Optional +from typing import Callable, Dict, List, Optional, Set from defusedxml.ElementTree import fromstring @@ -20,85 +20,7 @@ from .data_freshness_policy_item import DataFreshnessPolicyItem -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') - """ - +class WorkbookItem(object): def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, show_tabs: bool = False) -> None: self._connections = None self._content_url = None @@ -113,15 +35,15 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, self._revisions = None self._size = None self._updated_at = None - self._views: Optional[Callable[[], list[ViewItem]]] = None + self._views: Optional[Callable[[], List[ViewItem]]] = None self.name = name self._description = None self.owner_id: Optional[str] = None # workaround for Personal Space workbooks without a project self.project_id: Optional[str] = project_id or uuid.uuid4().__str__() self.show_tabs = show_tabs - self.hidden_views: Optional[list[str]] = None - self.tags: set[str] = set() + self.hidden_views: Optional[List[str]] = None + self.tags: Set[str] = set() self.data_acceleration_config = { "acceleration_enabled": None, "accelerate_now": None, @@ -134,7 +56,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, return None def __str__(self): - return "".format( + return "".format( self._id, self.name, self.content_url, self.project_id ) @@ -142,14 +64,14 @@ def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @property - def connections(self) -> list[ConnectionItem]: + def connections(self) -> List[ConnectionItem]: if self._connections is None: error = "Workbook item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self) -> list[PermissionsRule]: + def permissions(self) -> List[PermissionsRule]: if self._permissions is None: error = "Workbook item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -230,7 +152,7 @@ def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at @property - def views(self) -> list[ViewItem]: + def views(self) -> List[ViewItem]: # Views can be set in an initial workbook response OR by a call # to Server. Without getting too fancy, I think we can rely on # returning a list from the response, until they call @@ -269,7 +191,7 @@ def data_freshness_policy(self, value): self._data_freshness_policy = value @property - def revisions(self) -> list[RevisionItem]: + def revisions(self) -> List[RevisionItem]: if self._revisions is None: error = "Workbook item must be populated with revisions first." raise UnpopulatedPropertyError(error) @@ -281,7 +203,7 @@ def _set_connections(self, connections): def _set_permissions(self, permissions): self._permissions = permissions - def _set_views(self, views: Callable[[], list[ViewItem]]) -> None: + def _set_views(self, views: Callable[[], List[ViewItem]]) -> None: self._views = views def _set_pdf(self, pdf: Callable[[], bytes]) -> None: @@ -394,7 +316,7 @@ def _set_values( self.data_freshness_policy = data_freshness_policy @classmethod - def from_response(cls, resp: str, ns: dict[str, str]) -> list["WorkbookItem"]: + def from_response(cls, resp: str, ns: Dict[str, str]) -> List["WorkbookItem"]: all_workbook_items = list() parsed_response = fromstring(resp) all_workbook_xml = parsed_response.findall(".//t:workbook", namespaces=ns) diff --git a/tableauserverclient/namespace.py b/tableauserverclient/namespace.py index 54ac46d8d..d225ecff6 100644 --- a/tableauserverclient/namespace.py +++ b/tableauserverclient/namespace.py @@ -11,7 +11,7 @@ class UnknownNamespaceError(Exception): pass -class Namespace: +class Namespace(object): def __init__(self): self._namespace = {"t": NEW_NAMESPACE} self._detected = False diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 87cc9460b..f5cd1d236 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 FailedSignInError, NotSignedInError +from tableauserverclient.server.endpoint.exceptions import NotSignedInError from tableauserverclient.server.endpoint import ( Auth, @@ -57,7 +57,6 @@ "Sort", "Server", "Pager", - "FailedSignInError", "NotSignedInError", "Auth", "CustomViews", diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 4211bb7ea..468d469a7 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -16,7 +16,7 @@ class Auth(Endpoint): - class contextmgr: + class contextmgr(object): def __init__(self, callback): self._callback = callback @@ -28,7 +28,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/auth" + return "{0}/auth".format(self.parent_srv.baseurl) @api(version="2.0") def sign_in(self, auth_req: "Credentials") -> contextmgr: @@ -41,32 +41,8 @@ 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" + url = "{0}/{1}".format(self.baseurl, "signin") signin_req = RequestFactory.Auth.signin_req(auth_req) server_response = self.parent_srv.session.post( url, data=signin_req, **self.parent_srv.http_options, allow_redirects=False @@ -87,25 +63,22 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None) self.parent_srv._set_auth(site_id, user_id, auth_token) - logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}") + logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id)) return Auth.contextmgr(self.sign_out) # We use the same request that username/password login uses for all auth types. # 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" + url = "{0}/{1}".format(self.baseurl, "signout") # If there are no auth tokens you're already signed out. No-op if not self.parent_srv.is_signed_in(): return @@ -115,34 +88,7 @@ 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" + url = "{0}/{1}".format(self.baseurl, "switchSite") switch_req = RequestFactory.Auth.switch_req(site_item.content_url) try: server_response = self.post_request(url, switch_req) @@ -158,14 +104,11 @@ def switch_site(self, site_item: "SiteItem") -> contextmgr: user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None) self.parent_srv._set_auth(site_id, user_id, auth_token) - logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}") + logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id)) return Auth.contextmgr(self.sign_out) @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" + url = "{0}/{1}".format(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 b02b05d78..57a5b0100 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -1,23 +1,15 @@ 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 typing import List, Optional, Tuple, Union -from tableauserverclient.config import BYTES_PER_MB, config +from tableauserverclient.config import BYTES_PER_MB, FILESIZE_LIMIT_MB 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, - PDFRequestOptions, - CSVRequestOptions, -) +from tableauserverclient.server import RequestFactory, RequestOptions, ImageRequestOptions from tableauserverclient.helpers.logging import logger @@ -41,11 +33,11 @@ class CustomViews(QuerysetEndpoint[CustomViewItem]): def __init__(self, parent_srv): - super().__init__(parent_srv) + super(CustomViews, self).__init__(parent_srv) @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/customviews" + return "{0}/sites/{1}/customviews".format(self.parent_srv.baseurl, self.parent_srv.site_id) @property def expurl(self) -> str: @@ -63,7 +55,7 @@ def expurl(self) -> str: """ @api(version="3.18") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[CustomViewItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[CustomViewItem], PaginationItem]: logger.info("Querying all custom views on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -76,8 +68,8 @@ def get_by_id(self, view_id: str) -> Optional[CustomViewItem]: if not view_id: error = "Custom view item missing ID." raise MissingRequiredFieldError(error) - logger.info(f"Querying custom view (ID: {view_id})") - url = f"{self.baseurl}/{view_id}" + logger.info("Querying custom view (ID: {0})".format(view_id)) + url = "{0}/{1}".format(self.baseurl, view_id) server_response = self.get_request(url) return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) @@ -91,53 +83,17 @@ def image_fetcher(): return self._get_view_image(view_item, req_options) view_item._set_image(image_fetcher) - logger.info(f"Populated image for custom view (ID: {view_item.id})") + logger.info("Populated image for custom view (ID: {0})".format(view_item.id)) def _get_view_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes: - url = f"{self.baseurl}/{view_item.id}/image" + url = "{0}/{1}/image".format(self.baseurl, view_item.id) server_response = self.get_request(url, req_options) image = server_response.content return image - @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) + """ + Not yet implemented: pdf or csv exports + """ @api(version="3.18") def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: @@ -149,10 +105,10 @@ def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: return view_item # Update the custom view owner or name - url = f"{self.baseurl}/{view_item.id}" + url = "{0}/{1}".format(self.baseurl, view_item.id) update_req = RequestFactory.CustomView.update_req(view_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated custom view (ID: {view_item.id})") + logger.info("Updated custom view (ID: {0})".format(view_item.id)) return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) # Delete 1 view by id @@ -161,9 +117,9 @@ def delete(self, view_id: str) -> None: if not view_id: error = "Custom View ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{view_id}" + url = "{0}/{1}".format(self.baseurl, view_id) self.delete_request(url) - logger.info(f"Deleted single custom view (ID: {view_id})") + logger.info("Deleted single custom view (ID: {0})".format(view_id)) @api(version="3.21") def download(self, view_item: CustomViewItem, file: PathOrFileW) -> PathOrFileW: @@ -188,7 +144,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 >= config.FILESIZE_LIMIT_MB * BYTES_PER_MB: + if size >= 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/data_acceleration_report_endpoint.py b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py index 579001156..256a6e766 100644 --- a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py +++ b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py @@ -10,14 +10,14 @@ class DataAccelerationReport(Endpoint): def __init__(self, parent_srv): - super().__init__(parent_srv) + super(DataAccelerationReport, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @property def baseurl(self): - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/dataAccelerationReport" + return "{0}/sites/{1}/dataAccelerationReport".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.8") def get(self, req_options=None): diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py index ba3ecd74f..fd02d2e4a 100644 --- a/tableauserverclient/server/endpoint/data_alert_endpoint.py +++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py @@ -7,7 +7,7 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, TYPE_CHECKING, Union +from typing import List, Optional, TYPE_CHECKING, Tuple, Union if TYPE_CHECKING: @@ -17,14 +17,14 @@ class DataAlerts(Endpoint): def __init__(self, parent_srv: "Server") -> None: - super().__init__(parent_srv) + super(DataAlerts, self).__init__(parent_srv) @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/dataAlerts" + return "{0}/sites/{1}/dataAlerts".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.2") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[DataAlertItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[DataAlertItem], PaginationItem]: logger.info("Querying all dataAlerts on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -38,8 +38,8 @@ def get_by_id(self, dataAlert_id: str) -> DataAlertItem: if not dataAlert_id: error = "dataAlert ID undefined." raise ValueError(error) - logger.info(f"Querying single dataAlert (ID: {dataAlert_id})") - url = f"{self.baseurl}/{dataAlert_id}" + logger.info("Querying single dataAlert (ID: {0})".format(dataAlert_id)) + url = "{0}/{1}".format(self.baseurl, dataAlert_id) server_response = self.get_request(url) return DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -55,9 +55,9 @@ def delete(self, dataAlert: Union[DataAlertItem, str]) -> None: error = "Dataalert ID undefined." raise ValueError(error) # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id - url = f"{self.baseurl}/{dataAlert_id}" + url = "{0}/{1}".format(self.baseurl, dataAlert_id) self.delete_request(url) - logger.info(f"Deleted single dataAlert (ID: {dataAlert_id})") + logger.info("Deleted single dataAlert (ID: {0})".format(dataAlert_id)) @api(version="3.2") def delete_user_from_alert(self, dataAlert: Union[DataAlertItem, str], user: Union[UserItem, str]) -> None: @@ -80,9 +80,9 @@ def delete_user_from_alert(self, dataAlert: Union[DataAlertItem, str], user: Uni error = "User ID undefined." raise ValueError(error) # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id - url = f"{self.baseurl}/{dataAlert_id}/users/{user_id}" + url = "{0}/{1}/users/{2}".format(self.baseurl, dataAlert_id, user_id) self.delete_request(url) - logger.info(f"Deleted User (ID {user_id}) from dataAlert (ID: {dataAlert_id})") + logger.info("Deleted User (ID {0}) from dataAlert (ID: {1})".format(user_id, dataAlert_id)) @api(version="3.2") def add_user_to_alert(self, dataAlert_item: DataAlertItem, user: Union[UserItem, str]) -> UserItem: @@ -98,10 +98,10 @@ def add_user_to_alert(self, dataAlert_item: DataAlertItem, user: Union[UserItem, if not user_id: error = "User ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{dataAlert_item.id}/users" + url = "{0}/{1}/users".format(self.baseurl, dataAlert_item.id) update_req = RequestFactory.DataAlert.add_user_to_alert(dataAlert_item, user_id) server_response = self.post_request(url, update_req) - logger.info(f"Added user (ID {user_id}) to dataAlert item (ID: {dataAlert_item.id})") + logger.info("Added user (ID {0}) to dataAlert item (ID: {1})".format(user_id, dataAlert_item.id)) added_user = UserItem.from_response(server_response.content, self.parent_srv.namespace)[0] return added_user @@ -111,9 +111,9 @@ def update(self, dataAlert_item: DataAlertItem) -> DataAlertItem: error = "Dataalert item missing ID." raise MissingRequiredFieldError(error) - url = f"{self.baseurl}/{dataAlert_item.id}" + url = "{0}/{1}".format(self.baseurl, dataAlert_item.id) update_req = RequestFactory.DataAlert.update_req(dataAlert_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated dataAlert item (ID: {dataAlert_item.id})") + logger.info("Updated dataAlert item (ID: {0})".format(dataAlert_item.id)) updated_dataAlert = DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_dataAlert diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index c0e106eb2..2f8fece07 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -1,6 +1,5 @@ import logging -from typing import Union -from collections.abc import Iterable +from typing import Union, Iterable, Set from tableauserverclient.server.endpoint.default_permissions_endpoint import _DefaultPermissionsEndpoint from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint @@ -16,7 +15,7 @@ class Databases(Endpoint, TaggingMixin): def __init__(self, parent_srv): - super().__init__(parent_srv) + super(Databases, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @@ -24,7 +23,7 @@ def __init__(self, parent_srv): @property def baseurl(self): - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/databases" + return "{0}/sites/{1}/databases".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.5") def get(self, req_options=None): @@ -41,8 +40,8 @@ def get_by_id(self, database_id): if not database_id: error = "database ID undefined." raise ValueError(error) - logger.info(f"Querying single database (ID: {database_id})") - url = f"{self.baseurl}/{database_id}" + logger.info("Querying single database (ID: {0})".format(database_id)) + url = "{0}/{1}".format(self.baseurl, database_id) server_response = self.get_request(url) return DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -51,9 +50,9 @@ def delete(self, database_id): if not database_id: error = "Database ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{database_id}" + url = "{0}/{1}".format(self.baseurl, database_id) self.delete_request(url) - logger.info(f"Deleted single database (ID: {database_id})") + logger.info("Deleted single database (ID: {0})".format(database_id)) @api(version="3.5") def update(self, database_item): @@ -61,10 +60,10 @@ def update(self, database_item): error = "Database item missing ID." raise MissingRequiredFieldError(error) - url = f"{self.baseurl}/{database_item.id}" + url = "{0}/{1}".format(self.baseurl, database_item.id) update_req = RequestFactory.Database.update_req(database_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated database item (ID: {database_item.id})") + logger.info("Updated database item (ID: {0})".format(database_item.id)) updated_database = DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_database @@ -79,10 +78,10 @@ def column_fetcher(): return self._get_tables_for_database(database_item) database_item._set_tables(column_fetcher) - logger.info(f"Populated tables for database (ID: {database_item.id}") + logger.info("Populated tables for database (ID: {0}".format(database_item.id)) def _get_tables_for_database(self, database_item): - url = f"{self.baseurl}/{database_item.id}/tables" + url = "{0}/{1}/tables".format(self.baseurl, database_item.id) server_response = self.get_request(url) tables = TableItem.from_response(server_response.content, self.parent_srv.namespace) return tables @@ -128,7 +127,7 @@ def delete_dqw(self, item): self._data_quality_warnings.clear(item) @api(version="3.9") - def add_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> set[str]: + def add_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> Set[str]: return super().add_tags(item, tags) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 6bd809c28..7f3a47075 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -6,8 +6,7 @@ from contextlib import closing from pathlib import Path -from typing import Optional, TYPE_CHECKING, Union -from collections.abc import Iterable, Mapping, Sequence +from typing import Iterable, List, Mapping, Optional, Sequence, Set, Tuple, TYPE_CHECKING, Union from tableauserverclient.helpers.headers import fix_filename from tableauserverclient.server.query import QuerySet @@ -23,7 +22,7 @@ from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin -from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, BYTES_PER_MB, config +from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, config from tableauserverclient.filesys_helpers import ( make_download_path, get_file_type, @@ -58,7 +57,7 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]): def __init__(self, parent_srv: "Server") -> None: - super().__init__(parent_srv) + super(Datasources, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "datasource") @@ -66,11 +65,11 @@ def __init__(self, parent_srv: "Server") -> None: @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/datasources" + return "{0}/sites/{1}/datasources".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all datasources @api(version="2.0") - def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[DatasourceItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[DatasourceItem], PaginationItem]: logger.info("Querying all datasources on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -84,8 +83,8 @@ def get_by_id(self, datasource_id: str) -> DatasourceItem: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) - logger.info(f"Querying single datasource (ID: {datasource_id})") - url = f"{self.baseurl}/{datasource_id}" + logger.info("Querying single datasource (ID: {0})".format(datasource_id)) + url = "{0}/{1}".format(self.baseurl, datasource_id) server_response = self.get_request(url) return DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -100,10 +99,10 @@ def connections_fetcher(): return self._get_datasource_connections(datasource_item) datasource_item._set_connections(connections_fetcher) - logger.info(f"Populated connections for datasource (ID: {datasource_item.id})") + logger.info("Populated connections for datasource (ID: {0})".format(datasource_item.id)) def _get_datasource_connections(self, datasource_item, req_options=None): - url = f"{self.baseurl}/{datasource_item.id}/connections" + url = "{0}/{1}/connections".format(self.baseurl, datasource_item.id) server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -114,9 +113,9 @@ def delete(self, datasource_id: str) -> None: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{datasource_id}" + url = "{0}/{1}".format(self.baseurl, datasource_id) self.delete_request(url) - logger.info(f"Deleted single datasource (ID: {datasource_id})") + logger.info("Deleted single datasource (ID: {0})".format(datasource_id)) # Download 1 datasource by id @api(version="2.0") @@ -153,11 +152,11 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: self.update_tags(datasource_item) # Update the datasource itself - url = f"{self.baseurl}/{datasource_item.id}" + url = "{0}/{1}".format(self.baseurl, datasource_item.id) update_req = RequestFactory.Datasource.update_req(datasource_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated datasource item (ID: {datasource_item.id})") + logger.info("Updated datasource item (ID: {0})".format(datasource_item.id)) updated_datasource = copy.copy(datasource_item) return updated_datasource._parse_common_elements(server_response.content, self.parent_srv.namespace) @@ -166,7 +165,7 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: def update_connection( self, datasource_item: DatasourceItem, connection_item: ConnectionItem ) -> Optional[ConnectionItem]: - url = f"{self.baseurl}/{datasource_item.id}/connections/{connection_item.id}" + url = "{0}/{1}/connections/{2}".format(self.baseurl, datasource_item.id, connection_item.id) update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) @@ -175,16 +174,18 @@ def update_connection( return None if len(connections) > 1: - logger.debug(f"Multiple connections returned ({len(connections)})") + logger.debug("Multiple connections returned ({0})".format(len(connections))) connection = list(filter(lambda x: x.id == connection_item.id, connections))[0] - logger.info(f"Updated datasource item (ID: {datasource_item.id} & connection item {connection_item.id}") + logger.info( + "Updated datasource item (ID: {0} & connection item {1}".format(datasource_item.id, connection_item.id) + ) return connection @api(version="2.8") def refresh(self, datasource_item: DatasourceItem) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) - url = f"{self.baseurl}/{id_}/refresh" + url = "{0}/{1}/refresh".format(self.baseurl, id_) empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -193,7 +194,7 @@ def refresh(self, datasource_item: DatasourceItem) -> JobItem: @api(version="3.5") def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) - url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}" + url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -202,7 +203,7 @@ def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) @api(version="3.5") def delete_extract(self, datasource_item: DatasourceItem) -> None: id_ = getattr(datasource_item, "id", datasource_item) - url = f"{self.baseurl}/{id_}/deleteExtract" + url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @@ -222,12 +223,12 @@ def publish( if isinstance(file, (os.PathLike, str)): if not os.path.isfile(file): error = "File path does not lead to an existing file." - raise OSError(error) + raise IOError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] file_size = os.path.getsize(file) - logger.debug(f"Publishing file `{filename}`, size `{file_size}`") + logger.debug("Publishing file `{}`, size `{}`".format(filename, file_size)) # If name is not defined, grab the name from the file to publish if not datasource_item.name: datasource_item.name = os.path.splitext(filename)[0] @@ -246,10 +247,10 @@ def publish( elif file_type == "xml": file_extension = "tds" else: - error = f"Unsupported file type {file_type}" + error = "Unsupported file type {}".format(file_type) raise ValueError(error) - filename = f"{datasource_item.name}.{file_extension}" + filename = "{}.{}".format(datasource_item.name, file_extension) file_size = get_file_object_size(file) else: @@ -260,27 +261,27 @@ def publish( raise ValueError(error) # Construct the url with the defined mode - url = f"{self.baseurl}?datasourceType={file_extension}" + url = "{0}?datasourceType={1}".format(self.baseurl, file_extension) if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: - url += f"&{mode.lower()}=true" + url += "&{0}=true".format(mode.lower()) if as_job: - url += "&{}=true".format("asJob") + url += "&{0}=true".format("asJob") # Determine if chunking is required (64MB is the limit for single upload method) - if file_size >= config.FILESIZE_LIMIT_MB * BYTES_PER_MB: + if file_size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: logger.info( "Publishing {} to server with chunking method (datasource over {}MB, chunk size {}MB)".format( - filename, config.FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB + filename, FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB ) ) upload_session_id = self.parent_srv.fileuploads.upload(file) - url = f"{url}&uploadSessionId={upload_session_id}" + url = "{0}&uploadSessionId={1}".format(url, upload_session_id) xml_request, content_type = RequestFactory.Datasource.publish_req_chunked( datasource_item, connection_credentials, connections ) else: - logger.info(f"Publishing {filename} to server") + logger.info("Publishing {0} to server".format(filename)) if isinstance(file, (Path, str)): with open(file, "rb") as f: @@ -308,11 +309,11 @@ def publish( if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Published {filename} (JOB_ID: {new_job.id}") + logger.info("Published {0} (JOB_ID: {1}".format(filename, new_job.id)) return new_job else: new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Published {filename} (ID: {new_datasource.id})") + logger.info("Published {0} (ID: {1})".format(filename, new_datasource.id)) return new_datasource @api(version="3.13") @@ -326,23 +327,23 @@ def update_hyper_data( ) -> JobItem: if isinstance(datasource_or_connection_item, DatasourceItem): datasource_id = datasource_or_connection_item.id - url = f"{self.baseurl}/{datasource_id}/data" + url = "{0}/{1}/data".format(self.baseurl, datasource_id) elif isinstance(datasource_or_connection_item, ConnectionItem): datasource_id = datasource_or_connection_item.datasource_id connection_id = datasource_or_connection_item.id - url = f"{self.baseurl}/{datasource_id}/connections/{connection_id}/data" + url = "{0}/{1}/connections/{2}/data".format(self.baseurl, datasource_id, connection_id) else: assert isinstance(datasource_or_connection_item, str) - url = f"{self.baseurl}/{datasource_or_connection_item}/data" + url = "{0}/{1}/data".format(self.baseurl, datasource_or_connection_item) if payload is not None: if not os.path.isfile(payload): error = "File path does not lead to an existing file." - raise OSError(error) + raise IOError(error) - logger.info(f"Uploading {payload} to server with chunking method for Update job") + logger.info("Uploading {0} to server with chunking method for Update job".format(payload)) upload_session_id = self.parent_srv.fileuploads.upload(payload) - url = f"{url}?uploadSessionId={upload_session_id}" + url = "{0}?uploadSessionId={1}".format(url, upload_session_id) json_request = json.dumps({"actions": actions}) parameters = {"headers": {"requestid": request_id}} @@ -355,7 +356,7 @@ def populate_permissions(self, item: DatasourceItem) -> None: self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, item: DatasourceItem, permission_item: list["PermissionsRule"]) -> None: + def update_permissions(self, item: DatasourceItem, permission_item: List["PermissionsRule"]) -> None: self._permissions.update(item, permission_item) @api(version="2.0") @@ -389,12 +390,12 @@ def revisions_fetcher(): return self._get_datasource_revisions(datasource_item) datasource_item._set_revisions(revisions_fetcher) - logger.info(f"Populated revisions for datasource (ID: {datasource_item.id})") + logger.info("Populated revisions for datasource (ID: {0})".format(datasource_item.id)) def _get_datasource_revisions( self, datasource_item: DatasourceItem, req_options: Optional["RequestOptions"] = None - ) -> list[RevisionItem]: - url = f"{self.baseurl}/{datasource_item.id}/revisions" + ) -> List[RevisionItem]: + url = "{0}/{1}/revisions".format(self.baseurl, datasource_item.id) server_response = self.get_request(url, req_options) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, datasource_item) return revisions @@ -412,9 +413,9 @@ def download_revision( error = "Datasource ID undefined." raise ValueError(error) if revision_number is None: - url = f"{self.baseurl}/{datasource_id}/content" + url = "{0}/{1}/content".format(self.baseurl, datasource_id) else: - url = f"{self.baseurl}/{datasource_id}/revisions/{revision_number}/content" + url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, datasource_id, revision_number) if not include_extract: url += "?includeExtract=False" @@ -436,7 +437,9 @@ def download_revision( f.write(chunk) return_path = os.path.abspath(download_path) - logger.info(f"Downloaded datasource revision {revision_number} to {return_path} (ID: {datasource_id})") + logger.info( + "Downloaded datasource revision {0} to {1} (ID: {2})".format(revision_number, return_path, datasource_id) + ) return return_path @api(version="2.3") @@ -446,17 +449,19 @@ def delete_revision(self, datasource_id: str, revision_number: str) -> None: url = "/".join([self.baseurl, datasource_id, "revisions", revision_number]) self.delete_request(url) - logger.info(f"Deleted single datasource revision (ID: {datasource_id}) (Revision: {revision_number})") + logger.info( + "Deleted single datasource revision (ID: {0}) (Revision: {1})".format(datasource_id, revision_number) + ) # a convenience method @api(version="2.8") def schedule_extract_refresh( self, schedule_id: str, item: DatasourceItem - ) -> list["AddResponse"]: # actually should return a task + ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item) @api(version="1.0") - def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> set[str]: + def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> Set[str]: return super().add_tags(item, tags) @api(version="1.0") diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 499324e8e..19112d713 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -4,8 +4,7 @@ from .exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory from tableauserverclient.models import DatabaseItem, PermissionsRule, ProjectItem, plural_type, Resource -from typing import TYPE_CHECKING, Callable, Optional, Union -from collections.abc import Sequence +from typing import TYPE_CHECKING, Callable, List, Optional, Sequence, Union if TYPE_CHECKING: from ..server import Server @@ -26,7 +25,7 @@ class _DefaultPermissionsEndpoint(Endpoint): """ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None: - super().__init__(parent_srv) + super(_DefaultPermissionsEndpoint, self).__init__(parent_srv) # owner_baseurl is the baseurl of the parent, a project or database. # It MUST be a lambda since we don't know the full site URL until we sign in. @@ -34,25 +33,23 @@ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> No self.owner_baseurl = owner_baseurl def __str__(self): - return f"" + return "".format(self.owner_baseurl()) __repr__ = __str__ def update_default_permissions( - 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)}" + self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Resource + ) -> List[PermissionsRule]: + url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), resource.id, plural_type(content_type)) update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace) - logger.info(f"Updated default {content_type} permissions for resource {resource.id}") + logger.info("Updated default {} permissions for resource {}".format(content_type, resource.id)) logger.info(permissions) return permissions - def delete_default_permission( - self, resource: BaseItem, rule: PermissionsRule, content_type: Union[Resource, str] - ) -> None: + def delete_default_permission(self, resource: BaseItem, rule: PermissionsRule, content_type: Resource) -> None: for capability, mode in rule.capabilities.items(): # Made readability better but line is too long, will make this look better url = ( @@ -68,27 +65,29 @@ def delete_default_permission( ) ) - logger.debug(f"Removing {mode} permission for capability {capability}") + logger.debug("Removing {0} permission for capability {1}".format(mode, capability)) self.delete_request(url) - logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}") + logger.info( + "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) + ) - def populate_default_permissions(self, item: BaseItem, content_type: Union[Resource, str]) -> None: + def populate_default_permissions(self, item: BaseItem, content_type: Resource) -> None: if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) - def permission_fetcher() -> list[PermissionsRule]: + def permission_fetcher() -> List[PermissionsRule]: return self._get_default_permissions(item, content_type) item._set_default_permissions(permission_fetcher, content_type) - logger.info(f"Populated default {content_type} permissions for item (ID: {item.id})") + logger.info("Populated default {0} permissions for item (ID: {1})".format(content_type, item.id)) def _get_default_permissions( - 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)}" + self, item: BaseItem, content_type: Resource, req_options: Optional["RequestOptions"] = None + ) -> List[PermissionsRule]: + url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), item.id, plural_type(content_type)) server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) logger.info({"content_type": content_type, "permissions": permissions}) diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index 90e31483b..5296523ee 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -10,35 +10,35 @@ class _DataQualityWarningEndpoint(Endpoint): def __init__(self, parent_srv, resource_type): - super().__init__(parent_srv) + super(_DataQualityWarningEndpoint, self).__init__(parent_srv) self.resource_type = resource_type @property def baseurl(self): - return "{}/sites/{}/dataQualityWarnings/{}".format( + return "{0}/sites/{1}/dataQualityWarnings/{2}".format( self.parent_srv.baseurl, self.parent_srv.site_id, self.resource_type ) def add(self, resource, warning): - url = f"{self.baseurl}/{resource.id}" + url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) add_req = RequestFactory.DQW.add_req(warning) response = self.post_request(url, add_req) warnings = DQWItem.from_response(response.content, self.parent_srv.namespace) - logger.info(f"Added dqw for resource {resource.id}") + logger.info("Added dqw for resource {0}".format(resource.id)) return warnings def update(self, resource, warning): - url = f"{self.baseurl}/{resource.id}" + url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) add_req = RequestFactory.DQW.update_req(warning) response = self.put_request(url, add_req) warnings = DQWItem.from_response(response.content, self.parent_srv.namespace) - logger.info(f"Added dqw for resource {resource.id}") + logger.info("Added dqw for resource {0}".format(resource.id)) return warnings def clear(self, resource): - url = f"{self.baseurl}/{resource.id}" + url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) return self.delete_request(url) def populate(self, item): @@ -50,10 +50,10 @@ def dqw_fetcher(): return self._get_data_quality_warnings(item) item._set_data_quality_warnings(dqw_fetcher) - logger.info(f"Populated permissions for item (ID: {item.id})") + logger.info("Populated permissions for item (ID: {0})".format(item.id)) def _get_data_quality_warnings(self, item, req_options=None): - url = f"{self.baseurl}/{item.id}" + url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=item.id) server_response = self.get_request(url, req_options) dqws = DQWItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 9e1160705..be0602df5 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -8,9 +8,12 @@ from typing import ( Any, Callable, + Dict, Generic, + List, Optional, TYPE_CHECKING, + Tuple, TypeVar, Union, ) @@ -19,7 +22,6 @@ from tableauserverclient.server.request_options import RequestOptions from tableauserverclient.server.endpoint.exceptions import ( - FailedSignInError, ServerResponseError, InternalServerError, NonXMLResponseError, @@ -54,7 +56,7 @@ def __init__(self, parent_srv: "Server"): async_response = None @staticmethod - def set_parameters(http_options, auth_token, content, content_type, parameters) -> dict[str, Any]: + def set_parameters(http_options, auth_token, content, content_type, parameters) -> Dict[str, Any]: parameters = parameters or {} parameters.update(http_options) if "headers" not in parameters: @@ -80,7 +82,7 @@ def set_user_agent(parameters): else: # only set the TSC user agent if not already populated _client_version: Optional[str] = get_versions()["version"] - parameters["headers"][USER_AGENT_HEADER] = f"Tableau Server Client/{_client_version}" + parameters["headers"][USER_AGENT_HEADER] = "Tableau Server Client/{}".format(_client_version) # result: parameters["headers"]["User-Agent"] is set # return explicitly for testing only @@ -88,12 +90,12 @@ def set_user_agent(parameters): def _blocking_request(self, method, url, parameters={}) -> Optional[Union["Response", Exception]]: response = None - logger.debug(f"[{datetime.timestamp()}] Begin blocking request to {url}") + logger.debug("[{}] Begin blocking request to {}".format(datetime.timestamp(), url)) try: response = method(url, **parameters) - logger.debug(f"[{datetime.timestamp()}] Call finished") + logger.debug("[{}] Call finished".format(datetime.timestamp())) except Exception as e: - logger.debug(f"Error making request to server: {e}") + logger.debug("Error making request to server: {}".format(e)) raise e return response @@ -109,13 +111,13 @@ def _make_request( content: Optional[bytes] = None, auth_token: Optional[str] = None, content_type: Optional[str] = None, - parameters: Optional[dict[str, Any]] = None, + parameters: Optional[Dict[str, Any]] = None, ) -> "Response": parameters = Endpoint.set_parameters( self.parent_srv.http_options, auth_token, content, content_type, parameters ) - logger.debug(f"request method {method.__name__}, url: {url}") + logger.debug("request method {}, url: {}".format(method.__name__, url)) if content: redacted = helpers.strings.redact_xml(content[:200]) # this needs to be under a trace or something, it's a LOT @@ -127,21 +129,21 @@ def _make_request( server_response: Optional[Union["Response", Exception]] = self.send_request_while_show_progress_threaded( method, url, parameters, request_timeout ) - logger.debug(f"[{datetime.timestamp()}] Async request returned: received {server_response}") + logger.debug("[{}] Async request returned: received {}".format(datetime.timestamp(), server_response)) # is this blocking retry really necessary? I guess if it was just the threading messing it up? if server_response is None: logger.debug(server_response) - logger.debug(f"[{datetime.timestamp()}] Async request failed: retrying") + logger.debug("[{}] Async request failed: retrying".format(datetime.timestamp())) server_response = self._blocking_request(method, url, parameters) if server_response is None: - logger.debug(f"[{datetime.timestamp()}] Request failed") + logger.debug("[{}] Request failed".format(datetime.timestamp())) raise RuntimeError if isinstance(server_response, Exception): raise server_response self._check_status(server_response, url) loggable_response = self.log_response_safely(server_response) - logger.debug(f"Server response from {url}") + logger.debug("Server response from {0}".format(url)) # uncomment the following to log full responses in debug mode # BE CAREFUL WHEN SHARING THESE RESULTS - MAY CONTAIN YOUR SENSITIVE DATA # logger.debug(loggable_response) @@ -152,16 +154,16 @@ def _make_request( return server_response def _check_status(self, server_response: "Response", url: Optional[str] = None): - logger.debug(f"Response status: {server_response}") + logger.debug("Response status: {}".format(server_response)) if not hasattr(server_response, "status_code"): - raise OSError("Response is not a http response?") + raise EnvironmentError("Response is not a http response?") if server_response.status_code >= 500: raise InternalServerError(server_response, url) elif server_response.status_code not in Success_codes: 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 FailedSignInError.from_response(server_response.content, self.parent_srv.namespace, url) + raise NotSignedInError(server_response.content, url) raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace, url) except ParseError: @@ -181,9 +183,9 @@ def log_response_safely(self, server_response: "Response") -> str: # content-type is an octet-stream accomplishes the same goal without eagerly loading content. # This check is to determine if the response is a text response (xml or otherwise) # so that we do not attempt to log bytes and other binary data. - loggable_response = f"Content type `{content_type}`" + loggable_response = "Content type `{}`".format(content_type) if content_type == "application/octet-stream": - loggable_response = f"A stream of type {content_type} [Truncated File Contents]" + loggable_response = "A stream of type {} [Truncated File Contents]".format(content_type) elif server_response.encoding and len(server_response.content) > 0: loggable_response = helpers.strings.redact_xml(server_response.content.decode(server_response.encoding)) return loggable_response @@ -311,7 +313,7 @@ def wrapper(self: E, *args: P.args, **kwargs: P.kwargs) -> R: for p in params_to_check: min_ver = Version(str(params[p])) if server_ver < min_ver: - error = f"{p!r} not available in {server_ver}, it will be ignored. Added in {min_ver}" + error = "{!r} not available in {}, it will be ignored. Added in {}".format(p, server_ver, min_ver) warnings.warn(error) return func(self, *args, **kwargs) @@ -351,5 +353,5 @@ def paginate(self, **kwargs) -> QuerySet[T]: return queryset @abc.abstractmethod - def get(self, request_options: Optional[RequestOptions] = None) -> tuple[list[T], PaginationItem]: + def get(self, request_options: Optional[RequestOptions] = None) -> Tuple[List[T], PaginationItem]: raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}") diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 77332da3e..9dfd38da6 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -1,31 +1,24 @@ from defusedxml.ElementTree import fromstring -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() +from typing import Optional class TableauError(Exception): pass -T = TypeVar("T") - - -class XMLError(TableauError): - def __init__(self, code: str, summary: str, detail: str, url: Optional[str] = None) -> None: +class ServerResponseError(TableauError): + def __init__(self, code, summary, detail, url=None): self.code = code self.summary = summary self.detail = detail self.url = url - super().__init__(str(self)) + super(ServerResponseError, self).__init__(str(self)) def __str__(self): - return f"\n\n\t{self.code}: {self.summary}\n\t\t{self.detail}" + return "\n\n\t{0}: {1}\n\t\t{2}".format(self.code, self.summary, self.detail) @classmethod - def from_response(cls, resp, ns, url): + def from_response(cls, resp, ns, url=None): # Check elements exist before .text parsed_response = fromstring(resp) try: @@ -40,10 +33,6 @@ def from_response(cls, resp, ns, url): 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,7 +40,7 @@ def __init__(self, server_response, request_url: Optional[str] = None): self.url = request_url or "server" def __str__(self): - return f"\n\nInternal error {self.code} at {self.url}\n{self.content}" + return "\n\nInternal error {0} at {1}\n{2}".format(self.code, self.url, self.content) class MissingRequiredFieldError(TableauError): @@ -62,11 +51,6 @@ 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/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 8330e6d2c..5f298f37e 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -20,13 +20,13 @@ class Favorites(Endpoint): @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/favorites" + return "{0}/sites/{1}/favorites".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Gets all favorites @api(version="2.5") def get(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: - logger.info(f"Querying all favorites for user {user_item.name}") - url = f"{self.baseurl}/{user_item.id}" + logger.info("Querying all favorites for user {0}".format(user_item.name)) + url = "{0}/{1}".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) user_item._favorites = FavoriteItem.from_response(server_response.content, self.parent_srv.namespace) @@ -34,53 +34,53 @@ def get(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) @api(version="3.15") def add_favorite(self, user_item: UserItem, content_type: str, item: TableauItem) -> "Response": - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_request(item.id, content_type, item.name) server_response = self.put_request(url, add_req) - logger.info(f"Favorited {item.name} for user (ID: {user_item.id})") + logger.info("Favorited {0} for user (ID: {1})".format(item.name, user_item.id)) return server_response @api(version="2.0") def add_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None: - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_workbook_req(workbook_item.id, workbook_item.name) server_response = self.put_request(url, add_req) - logger.info(f"Favorited {workbook_item.name} for user (ID: {user_item.id})") + logger.info("Favorited {0} for user (ID: {1})".format(workbook_item.name, user_item.id)) @api(version="2.0") def add_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None: - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_view_req(view_item.id, view_item.name) server_response = self.put_request(url, add_req) - logger.info(f"Favorited {view_item.name} for user (ID: {user_item.id})") + logger.info("Favorited {0} for user (ID: {1})".format(view_item.name, user_item.id)) @api(version="2.3") def add_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None: - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_datasource_req(datasource_item.id, datasource_item.name) server_response = self.put_request(url, add_req) - logger.info(f"Favorited {datasource_item.name} for user (ID: {user_item.id})") + logger.info("Favorited {0} for user (ID: {1})".format(datasource_item.name, user_item.id)) @api(version="3.1") def add_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None: - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_project_req(project_item.id, project_item.name) server_response = self.put_request(url, add_req) - logger.info(f"Favorited {project_item.name} for user (ID: {user_item.id})") + logger.info("Favorited {0} for user (ID: {1})".format(project_item.name, user_item.id)) @api(version="3.3") def add_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None: - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_flow_req(flow_item.id, flow_item.name) server_response = self.put_request(url, add_req) - logger.info(f"Favorited {flow_item.name} for user (ID: {user_item.id})") + logger.info("Favorited {0} for user (ID: {1})".format(flow_item.name, user_item.id)) @api(version="3.3") def add_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None: - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_request(metric_item.id, Resource.Metric, metric_item.name) server_response = self.put_request(url, add_req) - logger.info(f"Favorited metric {metric_item.name} for user (ID: {user_item.id})") + logger.info("Favorited metric {0} for user (ID: {1})".format(metric_item.name, user_item.id)) # ------- delete from favorites # Response: @@ -94,42 +94,42 @@ def add_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> N @api(version="3.15") def delete_favorite(self, user_item: UserItem, content_type: Resource, item: TableauItem) -> None: - url = f"{self.baseurl}/{user_item.id}/{content_type}/{item.id}" - logger.info(f"Removing favorite {content_type}({item.id}) for user (ID: {user_item.id})") + url = "{0}/{1}/{2}/{3}".format(self.baseurl, user_item.id, content_type, item.id) + logger.info("Removing favorite {0}({1}) for user (ID: {2})".format(content_type, item.id, user_item.id)) self.delete_request(url) @api(version="2.0") def delete_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None: - url = f"{self.baseurl}/{user_item.id}/workbooks/{workbook_item.id}" - logger.info(f"Removing favorite workbook {workbook_item.id} for user (ID: {user_item.id})") + url = "{0}/{1}/workbooks/{2}".format(self.baseurl, user_item.id, workbook_item.id) + logger.info("Removing favorite workbook {0} for user (ID: {1})".format(workbook_item.id, user_item.id)) self.delete_request(url) @api(version="2.0") def delete_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None: - url = f"{self.baseurl}/{user_item.id}/views/{view_item.id}" - logger.info(f"Removing favorite view {view_item.id} for user (ID: {user_item.id})") + url = "{0}/{1}/views/{2}".format(self.baseurl, user_item.id, view_item.id) + logger.info("Removing favorite view {0} for user (ID: {1})".format(view_item.id, user_item.id)) self.delete_request(url) @api(version="2.3") def delete_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None: - url = f"{self.baseurl}/{user_item.id}/datasources/{datasource_item.id}" - logger.info(f"Removing favorite {datasource_item.id} for user (ID: {user_item.id})") + url = "{0}/{1}/datasources/{2}".format(self.baseurl, user_item.id, datasource_item.id) + logger.info("Removing favorite {0} for user (ID: {1})".format(datasource_item.id, user_item.id)) self.delete_request(url) @api(version="3.1") def delete_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None: - url = f"{self.baseurl}/{user_item.id}/projects/{project_item.id}" - logger.info(f"Removing favorite project {project_item.id} for user (ID: {user_item.id})") + url = "{0}/{1}/projects/{2}".format(self.baseurl, user_item.id, project_item.id) + logger.info("Removing favorite project {0} for user (ID: {1})".format(project_item.id, user_item.id)) self.delete_request(url) @api(version="3.3") def delete_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None: - url = f"{self.baseurl}/{user_item.id}/flows/{flow_item.id}" - logger.info(f"Removing favorite flow {flow_item.id} for user (ID: {user_item.id})") + url = "{0}/{1}/flows/{2}".format(self.baseurl, user_item.id, flow_item.id) + logger.info("Removing favorite flow {0} for user (ID: {1})".format(flow_item.id, user_item.id)) self.delete_request(url) @api(version="3.15") def delete_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None: - url = f"{self.baseurl}/{user_item.id}/metrics/{metric_item.id}" - logger.info(f"Removing favorite metric {metric_item.id} for user (ID: {user_item.id})") + url = "{0}/{1}/metrics/{2}".format(self.baseurl, user_item.id, metric_item.id) + logger.info("Removing favorite metric {0} for user (ID: {1})".format(metric_item.id, user_item.id)) self.delete_request(url) diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 1ae10e72d..0d30797c1 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -9,11 +9,11 @@ class Fileuploads(Endpoint): def __init__(self, parent_srv): - super().__init__(parent_srv) + super(Fileuploads, self).__init__(parent_srv) @property def baseurl(self): - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/fileUploads" + return "{0}/sites/{1}/fileUploads".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="2.0") def initiate(self): @@ -21,14 +21,14 @@ def initiate(self): server_response = self.post_request(url, "") fileupload_item = FileuploadItem.from_response(server_response.content, self.parent_srv.namespace) upload_id = fileupload_item.upload_session_id - logger.info(f"Initiated file upload session (ID: {upload_id})") + logger.info("Initiated file upload session (ID: {0})".format(upload_id)) return upload_id @api(version="2.0") def append(self, upload_id, data, content_type): - url = f"{self.baseurl}/{upload_id}" + url = "{0}/{1}".format(self.baseurl, upload_id) server_response = self.put_request(url, data, content_type) - logger.info(f"Uploading a chunk to session (ID: {upload_id})") + logger.info("Uploading a chunk to session (ID: {0})".format(upload_id)) return FileuploadItem.from_response(server_response.content, self.parent_srv.namespace) def _read_chunks(self, file): @@ -52,10 +52,12 @@ def _read_chunks(self, file): def upload(self, file): upload_id = self.initiate() for chunk in self._read_chunks(file): - logger.debug(f"{datetime.timestamp()} processing chunk...") + logger.debug("{} processing chunk...".format(datetime.timestamp())) request, content_type = RequestFactory.Fileupload.chunk_req(chunk) - logger.debug(f"{datetime.timestamp()} created chunk request") + logger.debug("{} created chunk request".format(datetime.timestamp())) fileupload_item = self.append(upload_id, request, content_type) - logger.info(f"\t{datetime.timestamp()} Published {(fileupload_item.file_size / BYTES_PER_MB)}MB") - logger.info(f"File upload finished (ID: {upload_id})") + logger.info( + "\t{0} Published {1}MB".format(datetime.timestamp(), (fileupload_item.file_size / BYTES_PER_MB)) + ) + logger.info("File upload finished (ID: {0})".format(upload_id)) return upload_id diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 2c3bb84bc..c339a0645 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -1,9 +1,9 @@ import logging -from typing import Optional, TYPE_CHECKING, Union +from typing import List, Optional, Tuple, TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException, FlowRunCancelledException -from tableauserverclient.models import FlowRunItem +from tableauserverclient.models import FlowRunItem, PaginationItem from tableauserverclient.exponential_backoff import ExponentialBackoffTimer from tableauserverclient.helpers.logging import logger @@ -16,24 +16,22 @@ class FlowRuns(QuerysetEndpoint[FlowRunItem]): def __init__(self, parent_srv: "Server") -> None: - super().__init__(parent_srv) + super(FlowRuns, self).__init__(parent_srv) return None @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/flows/runs" + return "{0}/sites/{1}/flows/runs".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all flows @api(version="3.10") - # QuerysetEndpoint expects a PaginationItem to be returned, but FlowRuns - # does not return a PaginationItem. Suppressing the mypy error because the - # changes to the QuerySet class should permit this to function regardless. - def get(self, req_options: Optional["RequestOptions"] = None) -> list[FlowRunItem]: # type: ignore[override] + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[FlowRunItem], PaginationItem]: logger.info("Querying all flow runs on site") url = self.baseurl server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) all_flow_run_items = FlowRunItem.from_response(server_response.content, self.parent_srv.namespace) - return all_flow_run_items + return all_flow_run_items, pagination_item # Get 1 flow by id @api(version="3.10") @@ -41,21 +39,21 @@ def get_by_id(self, flow_run_id: str) -> FlowRunItem: if not flow_run_id: error = "Flow ID undefined." raise ValueError(error) - logger.info(f"Querying single flow (ID: {flow_run_id})") - url = f"{self.baseurl}/{flow_run_id}" + logger.info("Querying single flow (ID: {0})".format(flow_run_id)) + url = "{0}/{1}".format(self.baseurl, flow_run_id) server_response = self.get_request(url) return FlowRunItem.from_response(server_response.content, self.parent_srv.namespace)[0] # Cancel 1 flow run by id @api(version="3.10") - def cancel(self, flow_run_id: Union[str, FlowRunItem]) -> None: + def cancel(self, flow_run_id: str) -> None: if not flow_run_id: error = "Flow ID undefined." raise ValueError(error) id_ = getattr(flow_run_id, "id", flow_run_id) - url = f"{self.baseurl}/{id_}" + url = "{0}/{1}".format(self.baseurl, id_) self.put_request(url) - logger.info(f"Deleted single flow (ID: {id_})") + logger.info("Deleted single flow (ID: {0})".format(id_)) @api(version="3.10") def wait_for_job(self, flow_run_id: str, *, timeout: Optional[int] = None) -> FlowRunItem: @@ -71,7 +69,7 @@ def wait_for_job(self, flow_run_id: str, *, timeout: Optional[int] = None) -> Fl flow_run = self.get_by_id(flow_run_id) logger.debug(f"\tFlowRun {flow_run_id} progress={flow_run.progress}") - logger.info(f"FlowRun {flow_run_id} Completed: Status: {flow_run.status}") + logger.info("FlowRun {} Completed: Status: {}".format(flow_run_id, flow_run.status)) if flow_run.status == "Success": return flow_run diff --git a/tableauserverclient/server/endpoint/flow_task_endpoint.py b/tableauserverclient/server/endpoint/flow_task_endpoint.py index 9e21661e6..eea3f9710 100644 --- a/tableauserverclient/server/endpoint/flow_task_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_task_endpoint.py @@ -1,5 +1,5 @@ import logging -from typing import TYPE_CHECKING +from typing import List, Optional, Tuple, TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import Endpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -15,7 +15,7 @@ class FlowTasks(Endpoint): @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/flows" + return "{0}/sites/{1}/tasks/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.22") def create(self, flow_item: TaskItem) -> TaskItem: diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 7eb5dc3ba..53d072f50 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -5,8 +5,7 @@ import os from contextlib import closing from pathlib import Path -from typing import Optional, TYPE_CHECKING, Union -from collections.abc import Iterable +from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union from tableauserverclient.helpers.headers import fix_filename @@ -54,18 +53,18 @@ class Flows(QuerysetEndpoint[FlowItem], TaggingMixin[FlowItem]): def __init__(self, parent_srv): - super().__init__(parent_srv) + super(Flows, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "flow") @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/flows" + return "{0}/sites/{1}/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all flows @api(version="3.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[FlowItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[FlowItem], PaginationItem]: logger.info("Querying all flows on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -79,8 +78,8 @@ def get_by_id(self, flow_id: str) -> FlowItem: if not flow_id: error = "Flow ID undefined." raise ValueError(error) - logger.info(f"Querying single flow (ID: {flow_id})") - url = f"{self.baseurl}/{flow_id}" + logger.info("Querying single flow (ID: {0})".format(flow_id)) + url = "{0}/{1}".format(self.baseurl, flow_id) server_response = self.get_request(url) return FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -95,10 +94,10 @@ def connections_fetcher(): return self._get_flow_connections(flow_item) flow_item._set_connections(connections_fetcher) - logger.info(f"Populated connections for flow (ID: {flow_item.id})") + logger.info("Populated connections for flow (ID: {0})".format(flow_item.id)) - def _get_flow_connections(self, flow_item, req_options: Optional["RequestOptions"] = None) -> list[ConnectionItem]: - url = f"{self.baseurl}/{flow_item.id}/connections" + def _get_flow_connections(self, flow_item, req_options: Optional["RequestOptions"] = None) -> List[ConnectionItem]: + url = "{0}/{1}/connections".format(self.baseurl, flow_item.id) server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -109,9 +108,9 @@ def delete(self, flow_id: str) -> None: if not flow_id: error = "Flow ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{flow_id}" + url = "{0}/{1}".format(self.baseurl, flow_id) self.delete_request(url) - logger.info(f"Deleted single flow (ID: {flow_id})") + logger.info("Deleted single flow (ID: {0})".format(flow_id)) # Download 1 flow by id @api(version="3.3") @@ -119,7 +118,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path if not flow_id: error = "Flow ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{flow_id}/content" + url = "{0}/{1}/content".format(self.baseurl, flow_id) with closing(self.get_request(url, parameters={"stream": True})) as server_response: m = Message() @@ -138,7 +137,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path f.write(chunk) return_path = os.path.abspath(download_path) - logger.info(f"Downloaded flow to {return_path} (ID: {flow_id})") + logger.info("Downloaded flow to {0} (ID: {1})".format(return_path, flow_id)) return return_path # Update flow @@ -151,28 +150,28 @@ def update(self, flow_item: FlowItem) -> FlowItem: self._resource_tagger.update_tags(self.baseurl, flow_item) # Update the flow itself - url = f"{self.baseurl}/{flow_item.id}" + url = "{0}/{1}".format(self.baseurl, flow_item.id) update_req = RequestFactory.Flow.update_req(flow_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated flow item (ID: {flow_item.id})") + logger.info("Updated flow item (ID: {0})".format(flow_item.id)) updated_flow = copy.copy(flow_item) return updated_flow._parse_common_elements(server_response.content, self.parent_srv.namespace) # Update flow connections @api(version="3.3") def update_connection(self, flow_item: FlowItem, connection_item: ConnectionItem) -> ConnectionItem: - url = f"{self.baseurl}/{flow_item.id}/connections/{connection_item.id}" + url = "{0}/{1}/connections/{2}".format(self.baseurl, flow_item.id, connection_item.id) update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Updated flow item (ID: {flow_item.id} & connection item {connection_item.id}") + logger.info("Updated flow item (ID: {0} & connection item {1}".format(flow_item.id, connection_item.id)) return connection @api(version="3.3") def refresh(self, flow_item: FlowItem) -> JobItem: - url = f"{self.baseurl}/{flow_item.id}/run" + url = "{0}/{1}/run".format(self.baseurl, flow_item.id) empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -181,7 +180,7 @@ def refresh(self, flow_item: FlowItem) -> JobItem: # Publish flow @api(version="3.3") def publish( - self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: Optional[list[ConnectionItem]] = None + self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: Optional[List[ConnectionItem]] = None ) -> FlowItem: if not mode or not hasattr(self.parent_srv.PublishMode, mode): error = "Invalid mode defined." @@ -190,7 +189,7 @@ def publish( if isinstance(file, (str, os.PathLike)): if not os.path.isfile(file): error = "File path does not lead to an existing file." - raise OSError(error) + raise IOError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] @@ -214,30 +213,30 @@ def publish( elif file_type == "xml": file_extension = "tfl" else: - error = f"Unsupported file type {file_type}!" + error = "Unsupported file type {}!".format(file_type) raise ValueError(error) # Generate filename for file object. # This is needed when publishing the flow in a single request - filename = f"{flow_item.name}.{file_extension}" + filename = "{}.{}".format(flow_item.name, file_extension) file_size = get_file_object_size(file) else: raise TypeError("file should be a filepath or file object.") # Construct the url with the defined mode - url = f"{self.baseurl}?flowType={file_extension}" + url = "{0}?flowType={1}".format(self.baseurl, file_extension) if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: - url += f"&{mode.lower()}=true" + url += "&{0}=true".format(mode.lower()) # Determine if chunking is required (64MB is the limit for single upload method) if file_size >= FILESIZE_LIMIT: - logger.info(f"Publishing {filename} to server with chunking method (flow over 64MB)") + logger.info("Publishing {0} to server with chunking method (flow over 64MB)".format(filename)) upload_session_id = self.parent_srv.fileuploads.upload(file) - url = f"{url}&uploadSessionId={upload_session_id}" + url = "{0}&uploadSessionId={1}".format(url, upload_session_id) xml_request, content_type = RequestFactory.Flow.publish_req_chunked(flow_item, connections) else: - logger.info(f"Publishing {filename} to server") + logger.info("Publishing {0} to server".format(filename)) if isinstance(file, (str, Path)): with open(file, "rb") as f: @@ -260,7 +259,7 @@ def publish( raise err else: new_flow = FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Published {filename} (ID: {new_flow.id})") + logger.info("Published {0} (ID: {1})".format(filename, new_flow.id)) return new_flow @api(version="3.3") @@ -295,7 +294,7 @@ def delete_dqw(self, item: FlowItem) -> None: @api(version="3.3") def schedule_flow_run( self, schedule_id: str, item: FlowItem - ) -> list["AddResponse"]: # actually should return a task + ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, flow=item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowItem]: diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index c512b011b..8acf31692 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -8,8 +8,7 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, TYPE_CHECKING, Union -from collections.abc import Iterable +from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union from tableauserverclient.server.query import QuerySet @@ -20,10 +19,10 @@ class Groups(QuerysetEndpoint[GroupItem]): @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/groups" + return "{0}/sites/{1}/groups".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[GroupItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[GroupItem], PaginationItem]: """Gets all groups""" logger.info("Querying all groups on site") url = self.baseurl @@ -51,12 +50,12 @@ def user_pager(): def _get_users_for_group( self, group_item: GroupItem, req_options: Optional["RequestOptions"] = None - ) -> tuple[list[UserItem], PaginationItem]: - url = f"{self.baseurl}/{group_item.id}/users" + ) -> Tuple[List[UserItem], PaginationItem]: + url = "{0}/{1}/users".format(self.baseurl, group_item.id) server_response = self.get_request(url, req_options) user_item = UserItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) - logger.info(f"Populated users for group (ID: {group_item.id})") + logger.info("Populated users for group (ID: {0})".format(group_item.id)) return user_item, pagination_item @api(version="2.0") @@ -65,13 +64,13 @@ def delete(self, group_id: str) -> None: if not group_id: error = "Group ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{group_id}" + url = "{0}/{1}".format(self.baseurl, group_id) self.delete_request(url) - logger.info(f"Deleted single group (ID: {group_id})") + logger.info("Deleted single group (ID: {0})".format(group_id)) @api(version="2.0") def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem, JobItem]: - url = f"{self.baseurl}/{group_item.id}" + url = "{0}/{1}".format(self.baseurl, group_item.id) if not group_item.id: error = "Group item missing ID." @@ -84,7 +83,7 @@ def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem update_req = RequestFactory.Group.update_req(group_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated group item (ID: {group_item.id})") + logger.info("Updated group item (ID: {0})".format(group_item.id)) if as_job: return JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] else: @@ -119,9 +118,9 @@ def remove_user(self, group_item: GroupItem, user_id: str) -> None: if not user_id: error = "User ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{group_item.id}/users/{user_id}" + url = "{0}/{1}/users/{2}".format(self.baseurl, group_item.id, user_id) self.delete_request(url) - logger.info(f"Removed user (id: {user_id}) from group (ID: {group_item.id})") + logger.info("Removed user (id: {0}) from group (ID: {1})".format(user_id, group_item.id)) @api(version="3.21") def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> None: @@ -133,7 +132,7 @@ def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserIte url = f"{self.baseurl}/{group_id}/users/remove" add_req = RequestFactory.Group.remove_users_req(users) _ = self.put_request(url, add_req) - logger.info(f"Removed users to group (ID: {group_item.id})") + logger.info("Removed users to group (ID: {0})".format(group_item.id)) return None @api(version="2.0") @@ -145,15 +144,15 @@ def add_user(self, group_item: GroupItem, user_id: str) -> UserItem: if not user_id: error = "User ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{group_item.id}/users" + url = "{0}/{1}/users".format(self.baseurl, group_item.id) add_req = RequestFactory.Group.add_user_req(user_id) server_response = self.post_request(url, add_req) user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() - logger.info(f"Added user (id: {user_id}) to group (ID: {group_item.id})") + logger.info("Added user (id: {0}) to group (ID: {1})".format(user_id, group_item.id)) return user @api(version="3.21") - def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> list[UserItem]: + def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> List[UserItem]: """Adds multiple users to 1 group""" group_id = group_item.id if hasattr(group_item, "id") else group_item if not isinstance(group_id, str): @@ -163,7 +162,7 @@ def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]] add_req = RequestFactory.Group.add_users_req(users) server_response = self.post_request(url, add_req) users = UserItem.from_response(server_response.content, self.parent_srv.namespace) - logger.info(f"Added users to group (ID: {group_item.id})") + logger.info("Added users to group (ID: {0})".format(group_item.id)) return users def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[GroupItem]: diff --git a/tableauserverclient/server/endpoint/groupsets_endpoint.py b/tableauserverclient/server/endpoint/groupsets_endpoint.py index c7f5ed0e5..06e7cc627 100644 --- a/tableauserverclient/server/endpoint/groupsets_endpoint.py +++ b/tableauserverclient/server/endpoint/groupsets_endpoint.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional, TYPE_CHECKING, Union +from typing import List, Literal, Optional, Tuple, TYPE_CHECKING, Union from tableauserverclient.helpers.logging import logger from tableauserverclient.models.group_item import GroupItem @@ -27,7 +27,7 @@ def get( self, request_options: Optional[RequestOptions] = None, result_level: Optional[Literal["members", "local"]] = None, - ) -> tuple[list[GroupSetItem], PaginationItem]: + ) -> Tuple[List[GroupSetItem], PaginationItem]: logger.info("Querying all group sets on site") url = self.baseurl if result_level: diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 723d3dd38..ae8cf2633 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -11,24 +11,24 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, Union +from typing import List, Optional, Tuple, Union class Jobs(QuerysetEndpoint[BackgroundJobItem]): @property def baseurl(self): - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/jobs" + return "{0}/sites/{1}/jobs".format(self.parent_srv.baseurl, self.parent_srv.site_id) @overload # type: ignore[override] def get(self: Self, job_id: str, req_options: Optional[RequestOptionsBase] = None) -> JobItem: # type: ignore[override] ... @overload # type: ignore[override] - def get(self: Self, job_id: RequestOptionsBase, req_options: None) -> tuple[list[BackgroundJobItem], PaginationItem]: # type: ignore[override] + def get(self: Self, job_id: RequestOptionsBase, req_options: None) -> Tuple[List[BackgroundJobItem], PaginationItem]: # type: ignore[override] ... @overload # type: ignore[override] - def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> tuple[list[BackgroundJobItem], PaginationItem]: # type: ignore[override] + def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> Tuple[List[BackgroundJobItem], PaginationItem]: # type: ignore[override] ... @api(version="2.6") @@ -53,13 +53,13 @@ def cancel(self, job_id: Union[str, JobItem]): if isinstance(job_id, JobItem): job_id = job_id.id assert isinstance(job_id, str) - url = f"{self.baseurl}/{job_id}" + url = "{0}/{1}".format(self.baseurl, job_id) return self.put_request(url) @api(version="2.6") def get_by_id(self, job_id: str) -> JobItem: logger.info("Query for information about job " + job_id) - url = f"{self.baseurl}/{job_id}" + url = "{0}/{1}".format(self.baseurl, job_id) server_response = self.get_request(url) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job @@ -77,7 +77,7 @@ def wait_for_job(self, job_id: Union[str, JobItem], *, timeout: Optional[float] job = self.get_by_id(job_id) logger.debug(f"\tJob {job_id} progress={job.progress}") - logger.info(f"Job {job_id} Completed: Finish Code: {job.finish_code} - Notes:{job.notes}") + logger.info("Job {} Completed: Finish Code: {} - Notes:{}".format(job_id, job.finish_code, job.notes)) if job.finish_code == JobItem.FinishCode.Success: return job diff --git a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py index ede4d38e3..374130509 100644 --- a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py @@ -1,4 +1,4 @@ -from typing import Optional, Union +from typing import List, Optional, Tuple, Union from tableauserverclient.helpers.logging import logger from tableauserverclient.models.linked_tasks_item import LinkedTaskItem, LinkedTaskJobItem @@ -18,7 +18,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/linked" @api(version="3.15") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[LinkedTaskItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[LinkedTaskItem], PaginationItem]: logger.info("Querying all linked tasks on site") url = self.baseurl server_response = self.get_request(url, req_options) diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index e5dbcbcf8..38c3eebb6 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -50,11 +50,11 @@ def get_page_info(result): class Metadata(Endpoint): @property def baseurl(self): - return f"{self.parent_srv.server_address}/api/metadata/graphql" + return "{0}/api/metadata/graphql".format(self.parent_srv.server_address) @property def control_baseurl(self): - return f"{self.parent_srv.server_address}/api/metadata/v1/control" + return "{0}/api/metadata/v1/control".format(self.parent_srv.server_address) @api("3.5") def query(self, query, variables=None, abort_on_error=False, parameters=None): diff --git a/tableauserverclient/server/endpoint/metrics_endpoint.py b/tableauserverclient/server/endpoint/metrics_endpoint.py index 3fea1f5b6..ab1ec5852 100644 --- a/tableauserverclient/server/endpoint/metrics_endpoint.py +++ b/tableauserverclient/server/endpoint/metrics_endpoint.py @@ -8,7 +8,7 @@ import logging -from typing import Optional, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING, Tuple if TYPE_CHECKING: from ..request_options import RequestOptions @@ -20,18 +20,18 @@ class Metrics(QuerysetEndpoint[MetricItem]): def __init__(self, parent_srv: "Server") -> None: - super().__init__(parent_srv) + super(Metrics, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "metric") @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/metrics" + return "{0}/sites/{1}/metrics".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all metrics @api(version="3.9") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[MetricItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[MetricItem], PaginationItem]: logger.info("Querying all metrics on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -45,8 +45,8 @@ def get_by_id(self, metric_id: str) -> MetricItem: if not metric_id: error = "Metric ID undefined." raise ValueError(error) - logger.info(f"Querying single metric (ID: {metric_id})") - url = f"{self.baseurl}/{metric_id}" + logger.info("Querying single metric (ID: {0})".format(metric_id)) + url = "{0}/{1}".format(self.baseurl, metric_id) server_response = self.get_request(url) return MetricItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -56,9 +56,9 @@ def delete(self, metric_id: str) -> None: if not metric_id: error = "Metric ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{metric_id}" + url = "{0}/{1}".format(self.baseurl, metric_id) self.delete_request(url) - logger.info(f"Deleted single metric (ID: {metric_id})") + logger.info("Deleted single metric (ID: {0})".format(metric_id)) # Update metric @api(version="3.9") @@ -70,8 +70,8 @@ def update(self, metric_item: MetricItem) -> MetricItem: self._resource_tagger.update_tags(self.baseurl, metric_item) # Update the metric itself - url = f"{self.baseurl}/{metric_item.id}" + url = "{0}/{1}".format(self.baseurl, metric_item.id) update_req = RequestFactory.Metric.update_req(metric_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated metric item (ID: {metric_item.id})") + logger.info("Updated metric item (ID: {0})".format(metric_item.id)) return MetricItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index 10d420ff7..4433625f2 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -6,7 +6,7 @@ from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError -from typing import Callable, TYPE_CHECKING, Optional, Union +from typing import Callable, TYPE_CHECKING, List, Optional, Union from tableauserverclient.helpers.logging import logger @@ -25,7 +25,7 @@ class _PermissionsEndpoint(Endpoint): """ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None: - super().__init__(parent_srv) + super(_PermissionsEndpoint, self).__init__(parent_srv) # owner_baseurl is the baseurl of the parent. The MUST be a lambda # since we don't know the full site URL until we sign in. If @@ -33,18 +33,18 @@ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> No self.owner_baseurl = owner_baseurl def __str__(self): - return f"" + return "".format(self.owner_baseurl) - def update(self, resource: TableauItem, permissions: list[PermissionsRule]) -> list[PermissionsRule]: - url = f"{self.owner_baseurl()}/{resource.id}/permissions" + def update(self, resource: TableauItem, permissions: List[PermissionsRule]) -> List[PermissionsRule]: + url = "{0}/{1}/permissions".format(self.owner_baseurl(), resource.id) update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace) - logger.info(f"Updated permissions for resource {resource.id}: {permissions}") + logger.info("Updated permissions for resource {0}: {1}".format(resource.id, permissions)) return permissions - def delete(self, resource: TableauItem, rules: Union[PermissionsRule, list[PermissionsRule]]): + def delete(self, resource: TableauItem, rules: Union[PermissionsRule, List[PermissionsRule]]): # Delete is the only endpoint that doesn't take a list of rules # so let's fake it to keep it consistent # TODO that means we need error handling around the call @@ -54,7 +54,7 @@ def delete(self, resource: TableauItem, rules: Union[PermissionsRule, list[Permi for rule in rules: for capability, mode in rule.capabilities.items(): "/permissions/groups/group-id/capability-name/capability-mode" - url = "{}/{}/permissions/{}/{}/{}/{}".format( + url = "{0}/{1}/permissions/{2}/{3}/{4}/{5}".format( self.owner_baseurl(), resource.id, rule.grantee.tag_name + "s", @@ -63,11 +63,13 @@ def delete(self, resource: TableauItem, rules: Union[PermissionsRule, list[Permi mode, ) - logger.debug(f"Removing {mode} permission for capability {capability}") + logger.debug("Removing {0} permission for capability {1}".format(mode, capability)) self.delete_request(url) - logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}") + logger.info( + "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) + ) def populate(self, item: TableauItem): if not item.id: @@ -78,12 +80,12 @@ def permission_fetcher(): return self._get_permissions(item) item._set_permissions(permission_fetcher) - logger.info(f"Populated permissions for item (ID: {item.id})") + logger.info("Populated permissions for item (ID: {0})".format(item.id)) def _get_permissions(self, item: TableauItem, req_options: Optional["RequestOptions"] = None): - url = f"{self.owner_baseurl()}/{item.id}/permissions" + url = "{0}/{1}/permissions".format(self.owner_baseurl(), item.id) server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) - logger.info(f"Permissions for resource {item.id}: {permissions}") + logger.info("Permissions for resource {0}: {1}".format(item.id, permissions)) return permissions diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 74bb865c7..565817e37 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -5,10 +5,9 @@ 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 +from typing import List, Optional, Tuple, TYPE_CHECKING from tableauserverclient.server.query import QuerySet @@ -21,17 +20,17 @@ class Projects(QuerysetEndpoint[ProjectItem]): def __init__(self, parent_srv: "Server") -> None: - super().__init__(parent_srv) + super(Projects, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/projects" + return "{0}/sites/{1}/projects".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ProjectItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[ProjectItem], PaginationItem]: logger.info("Querying all projects on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -44,9 +43,9 @@ def delete(self, project_id: str) -> None: if not project_id: error = "Project ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{project_id}" + url = "{0}/{1}".format(self.baseurl, project_id) self.delete_request(url) - logger.info(f"Deleted single project (ID: {project_id})") + logger.info("Deleted single project (ID: {0})".format(project_id)) @api(version="2.0") def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem: @@ -55,10 +54,10 @@ def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte raise MissingRequiredFieldError(error) params = {"params": {RequestOptions.Field.PublishSamples: samples}} - url = f"{self.baseurl}/{project_item.id}" + url = "{0}/{1}".format(self.baseurl, project_item.id) update_req = RequestFactory.Project.update_req(project_item) server_response = self.put_request(url, update_req, XML_CONTENT_TYPE, params) - logger.info(f"Updated project item (ID: {project_item.id})") + logger.info("Updated project item (ID: {0})".format(project_item.id)) updated_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_project @@ -67,11 +66,11 @@ def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte params = {"params": {RequestOptions.Field.PublishSamples: samples}} url = self.baseurl if project_item._samples: - url = f"{self.baseurl}?publishSamples={project_item._samples}" + url = "{0}?publishSamples={1}".format(self.baseurl, project_item._samples) create_req = RequestFactory.Project.create_req(project_item) server_response = self.post_request(url, create_req, XML_CONTENT_TYPE, params) new_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Created new project (ID: {new_project.id})") + logger.info("Created new project (ID: {0})".format(new_project.id)) return new_project @api(version="2.0") @@ -79,135 +78,85 @@ def populate_permissions(self, item: ProjectItem) -> None: self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + def update_permissions(self, item, rules): return self._permissions.update(item, rules) @api(version="2.0") - def delete_permission(self, item: ProjectItem, rules: list[PermissionsRule]) -> None: + def delete_permission(self, item, rules): self._permissions.delete(item, rules) @api(version="2.1") - def populate_workbook_default_permissions(self, item: ProjectItem) -> None: + def populate_workbook_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Resource.Workbook) @api(version="2.1") - def populate_datasource_default_permissions(self, item: ProjectItem) -> None: + def populate_datasource_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Resource.Datasource) @api(version="3.2") - def populate_metric_default_permissions(self, item: ProjectItem) -> None: + def populate_metric_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Resource.Metric) @api(version="3.4") - def populate_datarole_default_permissions(self, item: ProjectItem) -> None: + def populate_datarole_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Resource.Datarole) @api(version="3.4") - def populate_flow_default_permissions(self, item: ProjectItem) -> None: + def populate_flow_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Resource.Flow) @api(version="3.4") - def populate_lens_default_permissions(self, item: ProjectItem) -> None: + def populate_lens_default_permissions(self, item): 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: ProjectItem, rules: list[PermissionsRule] - ) -> list[PermissionsRule]: + def update_workbook_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Workbook) @api(version="2.1") - def update_datasource_default_permissions( - self, item: ProjectItem, rules: list[PermissionsRule] - ) -> list[PermissionsRule]: + def update_datasource_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Datasource) @api(version="3.2") - def update_metric_default_permissions( - self, item: ProjectItem, rules: list[PermissionsRule] - ) -> list[PermissionsRule]: + def update_metric_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Metric) @api(version="3.4") - def update_datarole_default_permissions( - self, item: ProjectItem, rules: list[PermissionsRule] - ) -> list[PermissionsRule]: + def update_datarole_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Datarole) @api(version="3.4") - def update_flow_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + def update_flow_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Flow) @api(version="3.4") - def update_lens_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + def update_lens_default_permissions(self, item, rules): 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: ProjectItem, rule: PermissionsRule) -> None: + def delete_workbook_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Workbook) @api(version="2.1") - def delete_datasource_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + def delete_datasource_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Datasource) @api(version="3.2") - def delete_metric_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + def delete_metric_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Metric) @api(version="3.4") - def delete_datarole_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + def delete_datarole_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Datarole) @api(version="3.4") - def delete_flow_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + def delete_flow_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Flow) @api(version="3.4") - def delete_lens_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + def delete_lens_default_permissions(self, item, rule): 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/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 63c03b3e3..1894e3b8a 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,7 +1,6 @@ import abc import copy -from typing import Generic, Optional, Protocol, TypeVar, Union, TYPE_CHECKING, runtime_checkable -from collections.abc import Iterable +from typing import Generic, Iterable, Optional, Protocol, Set, TypeVar, Union, TYPE_CHECKING, runtime_checkable import urllib.parse from tableauserverclient.server.endpoint.endpoint import Endpoint, api @@ -25,7 +24,7 @@ class _ResourceTagger(Endpoint): # Add new tags to resource def _add_tags(self, baseurl, resource_id, tag_set): - url = f"{baseurl}/{resource_id}/tags" + url = "{0}/{1}/tags".format(baseurl, resource_id) add_req = RequestFactory.Tag.add_req(tag_set) try: @@ -40,7 +39,7 @@ def _add_tags(self, baseurl, resource_id, tag_set): # Delete a resource's tag by name def _delete_tag(self, baseurl, resource_id, tag_name): encoded_tag_name = urllib.parse.quote(tag_name) - url = f"{baseurl}/{resource_id}/tags/{encoded_tag_name}" + url = "{0}/{1}/tags/{2}".format(baseurl, resource_id, encoded_tag_name) try: self.delete_request(url) @@ -60,7 +59,7 @@ def update_tags(self, baseurl, resource_item): if add_set: resource_item.tags = self._add_tags(baseurl, resource_item.id, add_set) resource_item._initial_tags = copy.copy(resource_item.tags) - logger.info(f"Updated tags to {resource_item.tags}") + logger.info("Updated tags to {0}".format(resource_item.tags)) class Response(Protocol): @@ -69,8 +68,8 @@ class Response(Protocol): @runtime_checkable class Taggable(Protocol): - tags: set[str] - _initial_tags: set[str] + tags: Set[str] + _initial_tags: Set[str] @property def id(self) -> Optional[str]: @@ -96,14 +95,14 @@ def put_request(self, url, request) -> Response: def delete_request(self, url) -> None: pass - def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> set[str]: + def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> Set[str]: item_id = getattr(item, "id", item) if not isinstance(item_id, str): raise ValueError("ID not found.") if isinstance(tags, str): - tag_set = {tags} + tag_set = set([tags]) else: tag_set = set(tags) @@ -119,7 +118,7 @@ def delete_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> N raise ValueError("ID not found.") if isinstance(tags, str): - tag_set = {tags} + tag_set = set([tags]) else: tag_set = set(tags) @@ -159,9 +158,9 @@ def baseurl(self): return f"{self.parent_srv.baseurl}/tags" @api(version="3.9") - def batch_add(self, tags: Union[Iterable[str], str], content: content) -> set[str]: + def batch_add(self, tags: Union[Iterable[str], str], content: content) -> Set[str]: if isinstance(tags, str): - tag_set = {tags} + tag_set = set([tags]) else: tag_set = set(tags) @@ -171,9 +170,9 @@ def batch_add(self, tags: Union[Iterable[str], str], content: content) -> set[st return TagItem.from_response(server_response.content, self.parent_srv.namespace) @api(version="3.9") - def batch_delete(self, tags: Union[Iterable[str], str], content: content) -> set[str]: + def batch_delete(self, tags: Union[Iterable[str], str], content: content) -> Set[str]: if isinstance(tags, str): - tag_set = {tags} + tag_set = set([tags]) else: tag_set = set(tags) diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index eec4536f9..cfaee3324 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -2,7 +2,7 @@ import logging import warnings from collections import namedtuple -from typing import TYPE_CHECKING, Callable, Optional, Union +from typing import TYPE_CHECKING, Callable, List, Optional, Tuple, Union from .endpoint import Endpoint, api, parameter_added_in from .exceptions import MissingRequiredFieldError @@ -22,14 +22,14 @@ class Schedules(Endpoint): @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/schedules" + return "{0}/schedules".format(self.parent_srv.baseurl) @property def siteurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/schedules" + return "{0}/sites/{1}/schedules".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="2.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ScheduleItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[ScheduleItem], PaginationItem]: logger.info("Querying all schedules") url = self.baseurl server_response = self.get_request(url, req_options) @@ -42,8 +42,8 @@ def get_by_id(self, schedule_id): if not schedule_id: error = "No Schedule ID provided" raise ValueError(error) - logger.info(f"Querying a single schedule by id ({schedule_id})") - url = f"{self.baseurl}/{schedule_id}" + logger.info("Querying a single schedule by id ({})".format(schedule_id)) + url = "{0}/{1}".format(self.baseurl, schedule_id) server_response = self.get_request(url) return ScheduleItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -52,9 +52,9 @@ def delete(self, schedule_id: str) -> None: if not schedule_id: error = "Schedule ID undefined" raise ValueError(error) - url = f"{self.baseurl}/{schedule_id}" + url = "{0}/{1}".format(self.baseurl, schedule_id) self.delete_request(url) - logger.info(f"Deleted single schedule (ID: {schedule_id})") + logger.info("Deleted single schedule (ID: {0})".format(schedule_id)) @api(version="2.3") def update(self, schedule_item: ScheduleItem) -> ScheduleItem: @@ -62,10 +62,10 @@ def update(self, schedule_item: ScheduleItem) -> ScheduleItem: error = "Schedule item missing ID." raise MissingRequiredFieldError(error) - url = f"{self.baseurl}/{schedule_item.id}" + url = "{0}/{1}".format(self.baseurl, schedule_item.id) update_req = RequestFactory.Schedule.update_req(schedule_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated schedule item (ID: {schedule_item.id})") + logger.info("Updated schedule item (ID: {})".format(schedule_item.id)) updated_schedule = copy.copy(schedule_item) return updated_schedule._parse_common_tags(server_response.content, self.parent_srv.namespace) @@ -79,7 +79,7 @@ def create(self, schedule_item: ScheduleItem) -> ScheduleItem: create_req = RequestFactory.Schedule.create_req(schedule_item) server_response = self.post_request(url, create_req) new_schedule = ScheduleItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Created new schedule (ID: {new_schedule.id})") + logger.info("Created new schedule (ID: {})".format(new_schedule.id)) return new_schedule @api(version="2.8") @@ -91,12 +91,12 @@ def add_to_schedule( datasource: Optional["DatasourceItem"] = None, flow: Optional["FlowItem"] = None, task_type: Optional[str] = None, - ) -> list[AddResponse]: + ) -> List[AddResponse]: # There doesn't seem to be a good reason to allow one item of each type? if workbook and datasource: warnings.warn("Passing in multiple items for add_to_schedule will be deprecated", PendingDeprecationWarning) - items: list[ - tuple[str, Union[WorkbookItem, FlowItem, DatasourceItem], str, Callable[[Optional[str], str], bytes], str] + items: List[ + Tuple[str, Union[WorkbookItem, FlowItem, DatasourceItem], str, Callable[[Optional[str], str], bytes], str] ] = [] if workbook is not None: @@ -115,7 +115,8 @@ def add_to_schedule( ) # type:ignore[arg-type] results = (self._add_to(*x) for x in items) - return [x for x in results if not x.result] + # list() is needed for python 3.x compatibility + return list(filter(lambda x: not x.result, results)) # type:ignore[arg-type] def _add_to( self, @@ -132,13 +133,13 @@ def _add_to( item_task_type, ) -> AddResponse: id_ = resource.id - url = f"{self.siteurl}/{schedule_id}/{type_}s" + url = "{0}/{1}/{2}s".format(self.siteurl, schedule_id, type_) add_req = req_factory(id_, task_type=item_task_type) # type: ignore[call-arg, arg-type] response = self.put_request(url, add_req) error, warnings, task_created = ScheduleItem.parse_add_to_schedule_response(response, self.parent_srv.namespace) if task_created: - logger.info(f"Added {type_} to {id_} to schedule {schedule_id}") + logger.info("Added {} to {} to schedule {}".format(type_, id_, schedule_id)) if error is not None or warnings is not None: return AddResponse( diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index dc934496a..26aaf2910 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -1,5 +1,4 @@ import logging -from typing import Union from .endpoint import Endpoint, api from .exceptions import ServerResponseError @@ -22,49 +21,15 @@ def serverInfo(self): return self._info def __repr__(self): - return f"" + return "".format(self.serverInfo) @property - def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/serverInfo" + def baseurl(self): + return "{0}/serverInfo".format(self.parent_srv.baseurl) @api(version="2.4") - 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)) - """ + def get(self): + """Retrieve the server info for the server. This is an unauthenticated call""" 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 55d2a5ad0..dfec49ae1 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -8,49 +8,20 @@ from tableauserverclient.helpers.logging import logger -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, List, Optional, Tuple if TYPE_CHECKING: from ..request_options import RequestOptions 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" + return "{0}/sites".format(self.parent_srv.baseurl) # 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] - """ + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[SiteItem], PaginationItem]: logger.info("Querying all sites on site") logger.info("Requires Server Admin permissions") url = self.baseurl @@ -62,33 +33,6 @@ 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) @@ -96,45 +40,20 @@ def get_by_id(self, site_id: str) -> SiteItem: error = "You can only retrieve the site for which you are currently authenticated." raise ValueError(error) - logger.info(f"Querying single site (ID: {site_id})") - url = f"{self.baseurl}/{site_id}" + logger.info("Querying single site (ID: {0})".format(site_id)) + url = "{0}/{1}".format(self.baseurl, site_id) server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] # 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) print("Note: You can only work with the site for which you are currently authenticated") - logger.info(f"Querying single site (Name: {site_name})") - url = f"{self.baseurl}/{site_name}?key=name" + logger.info("Querying single site (Name: {0})".format(site_name)) + url = "{0}/{1}?key=name".format(self.baseurl, site_name) print(self.baseurl, url) server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -142,31 +61,6 @@ 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) @@ -174,51 +68,15 @@ def get_by_content_url(self, content_url: str) -> SiteItem: error = "You can only work with the site you are currently authenticated for" raise ValueError(error) - logger.info(f"Querying single site (Content URL: {content_url})") + logger.info("Querying single site (Content URL: {0})".format(content_url)) logger.debug("Querying other sites requires Server Admin permissions") - url = f"{self.baseurl}/{content_url}?key=contentUrl" + url = "{0}/{1}?key=contentUrl".format(self.baseurl, content_url) server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] # 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) @@ -232,94 +90,30 @@ def update(self, site_item: SiteItem) -> SiteItem: error = "You cannot set admin_mode to ContentOnly and also set a user quota" raise ValueError(error) - url = f"{self.baseurl}/{site_item.id}" + url = "{0}/{1}".format(self.baseurl, site_item.id) update_req = RequestFactory.Site.update_req(site_item, self.parent_srv) server_response = self.put_request(url, update_req) - logger.info(f"Updated site item (ID: {site_item.id})") + logger.info("Updated site item (ID: {0})".format(site_item.id)) update_site = copy.copy(site_item) return update_site._parse_common_tags(server_response.content, self.parent_srv.namespace) # 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) - url = f"{self.baseurl}/{site_id}" + url = "{0}/{1}".format(self.baseurl, site_id) if not site_id == self.parent_srv.site_id: error = "You can only delete the site you are currently authenticated for" raise ValueError(error) self.delete_request(url) self.parent_srv._clear_auth() - logger.info(f"Deleted single site (ID: {site_id}) and signed out") + logger.info("Deleted single site (ID: {0}) and signed out".format(site_id)) # 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" @@ -329,92 +123,33 @@ def create(self, site_item: SiteItem) -> SiteItem: create_req = RequestFactory.Site.create_req(site_item, self.parent_srv) server_response = self.post_request(url, create_req) new_site = SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Created new site (ID: {new_site.id})") + logger.info("Created new site (ID: {0})".format(new_site.id)) return new_site @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) - url = f"{self.baseurl}/{site_id}/encrypt-extracts" + url = "{0}/{1}/encrypt-extracts".format(self.baseurl, site_id) empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @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) - url = f"{self.baseurl}/{site_id}/decrypt-extracts" + url = "{0}/{1}/decrypt-extracts".format(self.baseurl, site_id) empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @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) - url = f"{self.baseurl}/{site_id}/reencrypt-extracts" + url = "{0}/{1}/reencrypt-extracts".format(self.baseurl, site_id) empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index c9abc9b06..a9f2e7bf5 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -7,7 +7,7 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING, Tuple if TYPE_CHECKING: from ..request_options import RequestOptions @@ -16,10 +16,10 @@ class Subscriptions(Endpoint): @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/subscriptions" + return "{0}/sites/{1}/subscriptions".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="2.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[SubscriptionItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[SubscriptionItem], PaginationItem]: logger.info("Querying all subscriptions for the site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -33,8 +33,8 @@ def get_by_id(self, subscription_id: str) -> SubscriptionItem: if not subscription_id: error = "No Subscription ID provided" raise ValueError(error) - logger.info(f"Querying a single subscription by id ({subscription_id})") - url = f"{self.baseurl}/{subscription_id}" + logger.info("Querying a single subscription by id ({})".format(subscription_id)) + url = "{}/{}".format(self.baseurl, subscription_id) server_response = self.get_request(url) return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -43,7 +43,7 @@ def create(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item: error = "No Susbcription provided" raise ValueError(error) - logger.info(f"Creating a subscription ({subscription_item})") + logger.info("Creating a subscription ({})".format(subscription_item)) url = self.baseurl create_req = RequestFactory.Subscription.create_req(subscription_item) server_response = self.post_request(url, create_req) @@ -54,17 +54,17 @@ def delete(self, subscription_id: str) -> None: if not subscription_id: error = "Subscription ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{subscription_id}" + url = "{0}/{1}".format(self.baseurl, subscription_id) self.delete_request(url) - logger.info(f"Deleted subscription (ID: {subscription_id})") + logger.info("Deleted subscription (ID: {0})".format(subscription_id)) @api(version="2.3") def update(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item.id: error = "Subscription item missing ID. Subscription must be retrieved from server first." raise MissingRequiredFieldError(error) - url = f"{self.baseurl}/{subscription_item.id}" + url = "{0}/{1}".format(self.baseurl, subscription_item.id) update_req = RequestFactory.Subscription.update_req(subscription_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated subscription item (ID: {subscription_item.id})") + logger.info("Updated subscription item (ID: {0})".format(subscription_item.id)) return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index 120d3ba9c..36ef78c0a 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,6 +1,5 @@ import logging -from typing import Union -from collections.abc import Iterable +from typing import Iterable, Set, Union from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint from tableauserverclient.server.endpoint.endpoint import api, Endpoint @@ -16,14 +15,14 @@ class Tables(Endpoint, TaggingMixin[TableItem]): def __init__(self, parent_srv): - super().__init__(parent_srv) + super(Tables, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "table") @property def baseurl(self): - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tables" + return "{0}/sites/{1}/tables".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.5") def get(self, req_options=None): @@ -40,8 +39,8 @@ def get_by_id(self, table_id): if not table_id: error = "table ID undefined." raise ValueError(error) - logger.info(f"Querying single table (ID: {table_id})") - url = f"{self.baseurl}/{table_id}" + logger.info("Querying single table (ID: {0})".format(table_id)) + url = "{0}/{1}".format(self.baseurl, table_id) server_response = self.get_request(url) return TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -50,9 +49,9 @@ def delete(self, table_id): if not table_id: error = "Database ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{table_id}" + url = "{0}/{1}".format(self.baseurl, table_id) self.delete_request(url) - logger.info(f"Deleted single table (ID: {table_id})") + logger.info("Deleted single table (ID: {0})".format(table_id)) @api(version="3.5") def update(self, table_item): @@ -60,10 +59,10 @@ def update(self, table_item): error = "table item missing ID." raise MissingRequiredFieldError(error) - url = f"{self.baseurl}/{table_item.id}" + url = "{0}/{1}".format(self.baseurl, table_item.id) update_req = RequestFactory.Table.update_req(table_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated table item (ID: {table_item.id})") + logger.info("Updated table item (ID: {0})".format(table_item.id)) updated_table = TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_table @@ -81,10 +80,10 @@ def column_fetcher(): ) table_item._set_columns(column_fetcher) - logger.info(f"Populated columns for table (ID: {table_item.id}") + logger.info("Populated columns for table (ID: {0}".format(table_item.id)) def _get_columns_for_table(self, table_item, req_options=None): - url = f"{self.baseurl}/{table_item.id}/columns" + url = "{0}/{1}/columns".format(self.baseurl, table_item.id) server_response = self.get_request(url, req_options) columns = ColumnItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -92,12 +91,12 @@ def _get_columns_for_table(self, table_item, req_options=None): @api(version="3.5") def update_column(self, table_item, column_item): - url = f"{self.baseurl}/{table_item.id}/columns/{column_item.id}" + url = "{0}/{1}/columns/{2}".format(self.baseurl, table_item.id, column_item.id) update_req = RequestFactory.Column.update_req(column_item) server_response = self.put_request(url, update_req) column = ColumnItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Updated table item (ID: {table_item.id} & column item {column_item.id}") + logger.info("Updated table item (ID: {0} & column item {1}".format(table_item.id, column_item.id)) return column @api(version="3.5") @@ -129,7 +128,7 @@ def delete_dqw(self, item): self._data_quality_warnings.clear(item) @api(version="3.9") - def add_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> set[str]: + def add_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> Set[str]: return super().add_tags(item, tags) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index eb82c43bc..a727a515f 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -1,5 +1,5 @@ import logging -from typing import Optional, TYPE_CHECKING +from typing import List, Optional, Tuple, TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import Endpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -15,7 +15,7 @@ class Tasks(Endpoint): @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks" + return "{0}/sites/{1}/tasks".format(self.parent_srv.baseurl, self.parent_srv.site_id) def __normalize_task_type(self, task_type: str) -> str: """ @@ -23,20 +23,20 @@ def __normalize_task_type(self, task_type: str) -> str: It is different than the tag "extractRefresh" used in the request body. """ if task_type == TaskItem.Type.ExtractRefresh: - return f"{task_type}es" + return "{}es".format(task_type) else: return task_type @api(version="2.6") def get( self, req_options: Optional["RequestOptions"] = None, task_type: str = TaskItem.Type.ExtractRefresh - ) -> tuple[list[TaskItem], PaginationItem]: + ) -> Tuple[List[TaskItem], PaginationItem]: if task_type == TaskItem.Type.DataAcceleration: self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") logger.info("Querying all %s tasks for the site", task_type) - url = f"{self.baseurl}/{self.__normalize_task_type(task_type)}" + url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(task_type)) server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -63,7 +63,7 @@ def create(self, extract_item: TaskItem) -> TaskItem: error = "No extract refresh provided" raise ValueError(error) logger.info("Creating an extract refresh %s", extract_item) - url = f"{self.baseurl}/{self.__normalize_task_type(TaskItem.Type.ExtractRefresh)}" + url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh)) create_req = RequestFactory.Task.create_extract_req(extract_item) server_response = self.post_request(url, create_req) return server_response.content @@ -74,7 +74,7 @@ def run(self, task_item: TaskItem) -> bytes: error = "Task item missing ID." raise MissingRequiredFieldError(error) - url = "{}/{}/{}/runNow".format( + url = "{0}/{1}/{2}/runNow".format( self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh), task_item.id, @@ -92,6 +92,6 @@ def delete(self, task_id: str, task_type: str = TaskItem.Type.ExtractRefresh) -> if not task_id: error = "No Task ID provided" raise ValueError(error) - url = f"{self.baseurl}/{self.__normalize_task_type(task_type)}/{task_id}" + url = "{0}/{1}/{2}".format(self.baseurl, self.__normalize_task_type(task_type), task_id) self.delete_request(url) logger.info("Deleted single task (ID: %s)", task_id) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index d81907ae9..c4b6418b7 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,6 +1,6 @@ import copy import logging -from typing import Optional +from typing import List, Optional, Tuple from tableauserverclient.server.query import QuerySet @@ -14,75 +14,13 @@ 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" + return "{0}/sites/{1}/users".format(self.parent_srv.baseurl, self.parent_srv.site_id) # 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]) - """ + def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[UserItem], PaginationItem]: logger.info("Querying all users on site") if req_options is None: @@ -98,253 +36,55 @@ 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) - logger.info(f"Querying single user (ID: {user_id})") - url = f"{self.baseurl}/{user_id}" + logger.info("Querying single user (ID: {0})".format(user_id)) + url = "{0}/{1}".format(self.baseurl, user_id) server_response = self.get_request(url) return UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() # 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) - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) update_req = RequestFactory.User.update_req(user_item, password) server_response = self.put_request(url, update_req) - logger.info(f"Updated user item (ID: {user_item.id})") + logger.info("Updated user item (ID: {0})".format(user_item.id)) updated_item = copy.copy(user_item) return updated_item._parse_common_tags(server_response.content, self.parent_srv.namespace) # 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) - url = f"{self.baseurl}/{user_id}" + url = "{0}/{1}".format(self.baseurl, user_id) if map_assets_to is not None: url += f"?mapAssetsTo={map_assets_to}" self.delete_request(url) - logger.info(f"Removed single user (ID: {user_id})") + logger.info("Removed single user (ID: {0})".format(user_id)) # 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}") + logger.info("Add user {}".format(user_item.name)) add_req = RequestFactory.User.add_req(user_item) server_response = self.post_request(url, add_req) logger.info(server_response) new_user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() - logger.info(f"Added new user (ID: {new_user.id})") + logger.info("Added new user (ID: {0})".format(new_user.id)) return new_user # Add new users to site. This does not actually perform a bulk action, it's syntactic sugar @api(version="2.0") - def add_all(self, users: list[UserItem]): + def add_all(self, users: List[UserItem]): created = [] failed = [] for user in users: @@ -358,7 +98,7 @@ def add_all(self, users: list[UserItem]): # helping the user by parsing a file they could have used to add users through the UI # line format: Username [required], password, display name, license, admin, publish @api(version="2.0") - def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[UserItem, ServerResponseError]]]: + def create_from_file(self, filepath: str) -> Tuple[List[UserItem], List[Tuple[UserItem, ServerResponseError]]]: created = [] failed = [] if not filepath.find("csv"): @@ -382,42 +122,6 @@ 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) @@ -429,71 +133,20 @@ def wb_pager(): def _get_wbs_for_user( self, user_item: UserItem, req_options: Optional[RequestOptions] = None - ) -> tuple[list[WorkbookItem], PaginationItem]: - url = f"{self.baseurl}/{user_item.id}/workbooks" + ) -> Tuple[List[WorkbookItem], PaginationItem]: + url = "{0}/{1}/workbooks".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) - logger.info(f"Populated workbooks for user (ID: {user_item.id})") + logger.info("Populated workbooks for user (ID: {0})".format(user_item.id)) workbook_item = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) 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) @@ -508,10 +161,10 @@ def groups_for_user_pager(): def _get_groups_for_user( self, user_item: UserItem, req_options: Optional[RequestOptions] = None - ) -> tuple[list[GroupItem], PaginationItem]: - url = f"{self.baseurl}/{user_item.id}/groups" + ) -> Tuple[List[GroupItem], PaginationItem]: + url = "{0}/{1}/groups".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) - logger.info(f"Populated groups for user (ID: {user_item.id})") + logger.info("Populated groups for user (ID: {0})".format(user_item.id)) group_item = GroupItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) return group_item, pagination_item diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 3709fc41d..f2ccf658e 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -11,8 +11,7 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, TYPE_CHECKING, Union -from collections.abc import Iterable, Iterator +from typing import Iterable, Iterator, List, Optional, Set, Tuple, TYPE_CHECKING, Union if TYPE_CHECKING: from tableauserverclient.server.request_options import ( @@ -26,22 +25,22 @@ class Views(QuerysetEndpoint[ViewItem], TaggingMixin[ViewItem]): def __init__(self, parent_srv): - super().__init__(parent_srv) + super(Views, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) # Used because populate_preview_image functionaliy requires workbook endpoint @property def siteurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}" + return "{0}/sites/{1}".format(self.parent_srv.baseurl, self.parent_srv.site_id) @property def baseurl(self) -> str: - return f"{self.siteurl}/views" + return "{0}/views".format(self.siteurl) @api(version="2.2") def get( self, req_options: Optional["RequestOptions"] = None, usage: bool = False - ) -> tuple[list[ViewItem], PaginationItem]: + ) -> Tuple[List[ViewItem], PaginationItem]: logger.info("Querying all views on site") url = self.baseurl if usage: @@ -56,8 +55,8 @@ def get_by_id(self, view_id: str, usage: bool = False) -> ViewItem: if not view_id: error = "View item missing ID." raise MissingRequiredFieldError(error) - logger.info(f"Querying single view (ID: {view_id})") - url = f"{self.baseurl}/{view_id}" + logger.info("Querying single view (ID: {0})".format(view_id)) + url = "{0}/{1}".format(self.baseurl, view_id) if usage: url += "?includeUsageStatistics=true" server_response = self.get_request(url) @@ -73,10 +72,10 @@ def image_fetcher(): return self._get_preview_for_view(view_item) view_item._set_preview_image(image_fetcher) - logger.info(f"Populated preview image for view (ID: {view_item.id})") + logger.info("Populated preview image for view (ID: {0})".format(view_item.id)) def _get_preview_for_view(self, view_item: ViewItem) -> bytes: - url = f"{self.siteurl}/workbooks/{view_item.workbook_id}/views/{view_item.id}/previewImage" + url = "{0}/workbooks/{1}/views/{2}/previewImage".format(self.siteurl, view_item.workbook_id, view_item.id) server_response = self.get_request(url) image = server_response.content return image @@ -91,10 +90,10 @@ def image_fetcher(): return self._get_view_image(view_item, req_options) view_item._set_image(image_fetcher) - logger.info(f"Populated image for view (ID: {view_item.id})") + logger.info("Populated image for view (ID: {0})".format(view_item.id)) def _get_view_image(self, view_item: ViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes: - url = f"{self.baseurl}/{view_item.id}/image" + url = "{0}/{1}/image".format(self.baseurl, view_item.id) server_response = self.get_request(url, req_options) image = server_response.content return image @@ -109,10 +108,10 @@ def pdf_fetcher(): return self._get_view_pdf(view_item, req_options) view_item._set_pdf(pdf_fetcher) - logger.info(f"Populated pdf for view (ID: {view_item.id})") + logger.info("Populated pdf for view (ID: {0})".format(view_item.id)) def _get_view_pdf(self, view_item: ViewItem, req_options: Optional["PDFRequestOptions"]) -> bytes: - url = f"{self.baseurl}/{view_item.id}/pdf" + url = "{0}/{1}/pdf".format(self.baseurl, view_item.id) server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @@ -127,10 +126,10 @@ def csv_fetcher(): return self._get_view_csv(view_item, req_options) view_item._set_csv(csv_fetcher) - logger.info(f"Populated csv for view (ID: {view_item.id})") + logger.info("Populated csv for view (ID: {0})".format(view_item.id)) def _get_view_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterator[bytes]: - url = f"{self.baseurl}/{view_item.id}/data" + url = "{0}/{1}/data".format(self.baseurl, view_item.id) with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: yield from server_response.iter_content(1024) @@ -145,10 +144,10 @@ def excel_fetcher(): return self._get_view_excel(view_item, req_options) view_item._set_excel(excel_fetcher) - logger.info(f"Populated excel for view (ID: {view_item.id})") + logger.info("Populated excel for view (ID: {0})".format(view_item.id)) def _get_view_excel(self, view_item: ViewItem, req_options: Optional["ExcelRequestOptions"]) -> Iterator[bytes]: - url = f"{self.baseurl}/{view_item.id}/crosstab/excel" + url = "{0}/{1}/crosstab/excel".format(self.baseurl, view_item.id) with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: yield from server_response.iter_content(1024) @@ -177,7 +176,7 @@ def update(self, view_item: ViewItem) -> ViewItem: return view_item @api(version="1.0") - def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> set[str]: + def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> Set[str]: return super().add_tags(item, tags) @api(version="1.0") diff --git a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py index 944b72502..f71db00cc 100644 --- a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py +++ b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py @@ -1,8 +1,7 @@ from functools import partial import json from pathlib import Path -from typing import Optional, TYPE_CHECKING, Union -from collections.abc import Iterable +from typing import Iterable, List, Optional, Set, TYPE_CHECKING, Tuple, Union from tableauserverclient.models.connection_item import ConnectionItem from tableauserverclient.models.pagination_item import PaginationItem @@ -29,7 +28,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/virtualConnections" @api(version="3.18") - def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[VirtualConnectionItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[VirtualConnectionItem], PaginationItem]: server_response = self.get_request(self.baseurl, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) virtual_connections = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace) @@ -45,7 +44,7 @@ def _connection_fetcher(): def _get_virtual_database_connections( self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None - ) -> tuple[list[ConnectionItem], PaginationItem]: + ) -> Tuple[List[ConnectionItem], PaginationItem]: server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/connections", req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -84,7 +83,7 @@ def update(self, virtual_connection: VirtualConnectionItem) -> VirtualConnection @api(version="3.23") def get_revisions( self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None - ) -> tuple[list[RevisionItem], PaginationItem]: + ) -> Tuple[List[RevisionItem], PaginationItem]: server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/revisions", req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, virtual_connection) @@ -160,7 +159,7 @@ def delete_permission(self, item, capability_item): @api(version="3.23") def add_tags( self, virtual_connection: Union[VirtualConnectionItem, str], tags: Union[Iterable[str], str] - ) -> set[str]: + ) -> Set[str]: return super().add_tags(virtual_connection, tags) @api(version="3.23") diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index 06643f99d..597f9c425 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING, Tuple if TYPE_CHECKING: from ..server import Server @@ -15,14 +15,14 @@ class Webhooks(Endpoint): def __init__(self, parent_srv: "Server") -> None: - super().__init__(parent_srv) + super(Webhooks, self).__init__(parent_srv) @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/webhooks" + return "{0}/sites/{1}/webhooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.6") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WebhookItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[WebhookItem], PaginationItem]: logger.info("Querying all Webhooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -35,8 +35,8 @@ def get_by_id(self, webhook_id: str) -> WebhookItem: if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) - logger.info(f"Querying single webhook (ID: {webhook_id})") - url = f"{self.baseurl}/{webhook_id}" + logger.info("Querying single webhook (ID: {0})".format(webhook_id)) + url = "{0}/{1}".format(self.baseurl, webhook_id) server_response = self.get_request(url) return WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -45,9 +45,9 @@ def delete(self, webhook_id: str) -> None: if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{webhook_id}" + url = "{0}/{1}".format(self.baseurl, webhook_id) self.delete_request(url) - logger.info(f"Deleted single webhook (ID: {webhook_id})") + logger.info("Deleted single webhook (ID: {0})".format(webhook_id)) @api(version="3.6") def create(self, webhook_item: WebhookItem) -> WebhookItem: @@ -56,7 +56,7 @@ def create(self, webhook_item: WebhookItem) -> WebhookItem: server_response = self.post_request(url, create_req) new_webhook = WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Created new webhook (ID: {new_webhook.id})") + logger.info("Created new webhook (ID: {0})".format(new_webhook.id)) return new_webhook @api(version="3.6") @@ -64,7 +64,7 @@ def test(self, webhook_id: str): if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{webhook_id}/test" + url = "{0}/{1}/test".format(self.baseurl, webhook_id) testOutcome = self.get_request(url) - logger.info(f"Testing webhook (ID: {webhook_id} returned {testOutcome})") + logger.info("Testing webhook (ID: {0} returned {1})".format(webhook_id, testOutcome)) return testOutcome diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 460017d1a..da6eda3de 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -7,7 +7,6 @@ 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 @@ -26,11 +25,15 @@ from tableauserverclient.server import RequestFactory from typing import ( + Iterable, + List, Optional, + Sequence, + Set, + Tuple, TYPE_CHECKING, Union, ) -from collections.abc import Iterable, Sequence if TYPE_CHECKING: from tableauserverclient.server import Server @@ -58,34 +61,18 @@ class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin[WorkbookItem]): def __init__(self, parent_srv: "Server") -> None: - super().__init__(parent_srv) + super(Workbooks, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) return None @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/workbooks" + return "{0}/sites/{1}/workbooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) # 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. - """ + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[WorkbookItem], PaginationItem]: logger.info("Querying all workbooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -96,44 +83,18 @@ 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) - logger.info(f"Querying single workbook (ID: {workbook_id})") - url = f"{self.baseurl}/{workbook_id}" + logger.info("Querying single workbook (ID: {0})".format(workbook_id)) + url = "{0}/{1}".format(self.baseurl, workbook_id) server_response = self.get_request(url) return WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @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" + url = "{0}/{1}/refresh".format(self.baseurl, id_) empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -146,37 +107,10 @@ def create_extract( workbook_item: WorkbookItem, encrypt: bool = False, includeAll: bool = True, - datasources: Optional[list["DatasourceItem"]] = None, + 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}" + url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) server_response = self.post_request(url, datasource_req) @@ -186,31 +120,8 @@ 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" + url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) server_response = self.post_request(url, datasource_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -219,24 +130,12 @@ 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) - url = f"{self.baseurl}/{workbook_id}" + url = "{0}/{1}".format(self.baseurl, workbook_id) self.delete_request(url) - logger.info(f"Deleted single workbook (ID: {workbook_id})") + logger.info("Deleted single workbook (ID: {0})".format(workbook_id)) # Update workbook @api(version="2.0") @@ -246,29 +145,6 @@ 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) @@ -276,47 +152,27 @@ def update( self.update_tags(workbook_item) # Update the workbook itself - url = f"{self.baseurl}/{workbook_item.id}" + url = "{0}/{1}".format(self.baseurl, workbook_item.id) if include_view_acceleration_status: url += "?includeViewAccelerationStatus=True" update_req = RequestFactory.Workbook.update_req(workbook_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated workbook item (ID: {workbook_item.id})") + logger.info("Updated workbook item (ID: {0})".format(workbook_item.id)) updated_workbook = copy.copy(workbook_item) return updated_workbook._parse_common_tags(server_response.content, self.parent_srv.namespace) # 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}" + url = "{0}/{1}/connections/{2}".format(self.baseurl, workbook_item.id, connection_item.id) update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Updated workbook item (ID: {workbook_item.id} & connection item {connection_item.id})") + logger.info( + "Updated workbook item (ID: {0} & connection item {1})".format(workbook_item.id, connection_item.id) + ) return connection # Download workbook contents with option of passing in filepath @@ -329,34 +185,6 @@ 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, @@ -367,48 +195,18 @@ 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) - def view_fetcher() -> list[ViewItem]: + def view_fetcher() -> List[ViewItem]: return self._get_views_for_workbook(workbook_item, usage) workbook_item._set_views(view_fetcher) - logger.info(f"Populated views for workbook (ID: {workbook_item.id})") + logger.info("Populated views for workbook (ID: {0})".format(workbook_item.id)) - def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> list[ViewItem]: - url = f"{self.baseurl}/{workbook_item.id}/views" + def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> List[ViewItem]: + url = "{0}/{1}/views".format(self.baseurl, workbook_item.id) if usage: url += "?includeUsageStatistics=true" server_response = self.get_request(url) @@ -422,36 +220,6 @@ 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) @@ -460,12 +228,12 @@ def connection_fetcher(): return self._get_workbook_connections(workbook_item) workbook_item._set_connections(connection_fetcher) - logger.info(f"Populated connections for workbook (ID: {workbook_item.id})") + logger.info("Populated connections for workbook (ID: {0})".format(workbook_item.id)) def _get_workbook_connections( self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None - ) -> list[ConnectionItem]: - url = f"{self.baseurl}/{workbook_item.id}/connections" + ) -> List[ConnectionItem]: + url = "{0}/{1}/connections".format(self.baseurl, workbook_item.id) server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -473,34 +241,6 @@ 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) @@ -509,46 +249,16 @@ def pdf_fetcher() -> bytes: return self._get_wb_pdf(workbook_item, req_options) workbook_item._set_pdf(pdf_fetcher) - logger.info(f"Populated pdf for workbook (ID: {workbook_item.id})") + logger.info("Populated pdf for workbook (ID: {0})".format(workbook_item.id)) def _get_wb_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: - url = f"{self.baseurl}/{workbook_item.id}/pdf" + url = "{0}/{1}/pdf".format(self.baseurl, workbook_item.id) server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @api(version="3.8") def populate_powerpoint(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: - """ - 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) @@ -557,10 +267,10 @@ def pptx_fetcher() -> bytes: return self._get_wb_pptx(workbook_item, req_options) workbook_item._set_powerpoint(pptx_fetcher) - logger.info(f"Populated powerpoint for workbook (ID: {workbook_item.id})") + logger.info("Populated powerpoint for workbook (ID: {0})".format(workbook_item.id)) def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: - url = f"{self.baseurl}/{workbook_item.id}/powerpoint" + url = "{0}/{1}/powerpoint".format(self.baseurl, workbook_item.id) server_response = self.get_request(url, req_options) pptx = server_response.content return pptx @@ -568,26 +278,6 @@ 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) @@ -596,75 +286,24 @@ def image_fetcher() -> bytes: return self._get_wb_preview_image(workbook_item) workbook_item._set_preview_image(image_fetcher) - logger.info(f"Populated preview image for workbook (ID: {workbook_item.id})") + logger.info("Populated preview image for workbook (ID: {0})".format(workbook_item.id)) def _get_wb_preview_image(self, workbook_item: WorkbookItem) -> bytes: - url = f"{self.baseurl}/{workbook_item.id}/previewImage" + url = "{0}/{1}/previewImage".format(self.baseurl, workbook_item.id) server_response = self.get_request(url) preview_image = server_response.content return preview_image @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: 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. - """ + def update_permissions(self, resource, rules): return self._permissions.update(resource, rules) @api(version="2.0") - 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 - """ + def delete_permission(self, item, capability_item): return self._permissions.delete(item, capability_item) @api(version="2.0") @@ -680,87 +319,10 @@ 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." - raise OSError(error) + raise IOError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] @@ -784,12 +346,12 @@ def publish( elif file_type == "xml": file_extension = "twb" else: - error = f"Unsupported file type {file_type}!" + error = "Unsupported file type {}!".format(file_type) raise ValueError(error) # Generate filename for file object. # This is needed when publishing the workbook in a single request - filename = f"{workbook_item.name}.{file_extension}" + filename = "{}.{}".format(workbook_item.name, file_extension) file_size = get_file_object_size(file) else: @@ -800,30 +362,30 @@ def publish( raise ValueError(error) # Construct the url with the defined mode - url = f"{self.baseurl}?workbookType={file_extension}" + url = "{0}?workbookType={1}".format(self.baseurl, file_extension) if mode == self.parent_srv.PublishMode.Overwrite: - url += f"&{mode.lower()}=true" + url += "&{0}=true".format(mode.lower()) elif mode == self.parent_srv.PublishMode.Append: error = "Workbooks cannot be appended." raise ValueError(error) if as_job: - url += "&{}=true".format("asJob") + url += "&{0}=true".format("asJob") if skip_connection_check: - url += "&{}=true".format("skipConnectionCheck") + url += "&{0}=true".format("skipConnectionCheck") # Determine if chunking is required (64MB is the limit for single upload method) if file_size >= FILESIZE_LIMIT: - logger.info(f"Publishing {workbook_item.name} to server with chunking method (workbook over 64MB)") + logger.info("Publishing {0} to server with chunking method (workbook over 64MB)".format(workbook_item.name)) upload_session_id = self.parent_srv.fileuploads.upload(file) - url = f"{url}&uploadSessionId={upload_session_id}" + url = "{0}&uploadSessionId={1}".format(url, upload_session_id) xml_request, content_type = RequestFactory.Workbook.publish_req_chunked( workbook_item, connections=connections, ) else: - logger.info(f"Publishing {filename} to server") + logger.info("Publishing {0} to server".format(filename)) if isinstance(file, (str, Path)): with open(file, "rb") as f: @@ -841,7 +403,7 @@ def publish( file_contents, connections=connections, ) - logger.debug(f"Request xml: {redact_xml(xml_request[:1000])} ") + logger.debug("Request xml: {0} ".format(redact_xml(xml_request[:1000]))) # Send the publishing request to server try: @@ -853,38 +415,16 @@ def publish( if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Published {workbook_item.name} (JOB_ID: {new_job.id}") + logger.info("Published {0} (JOB_ID: {1}".format(workbook_item.name, new_job.id)) return new_job else: new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Published {workbook_item.name} (ID: {new_workbook.id})") + logger.info("Published {0} (ID: {1})".format(workbook_item.name, new_workbook.id)) return new_workbook # 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) @@ -893,12 +433,12 @@ def revisions_fetcher(): return self._get_workbook_revisions(workbook_item) workbook_item._set_revisions(revisions_fetcher) - logger.info(f"Populated revisions for workbook (ID: {workbook_item.id})") + logger.info("Populated revisions for workbook (ID: {0})".format(workbook_item.id)) def _get_workbook_revisions( self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None - ) -> list[RevisionItem]: - url = f"{self.baseurl}/{workbook_item.id}/revisions" + ) -> List[RevisionItem]: + url = "{0}/{1}/revisions".format(self.baseurl, workbook_item.id) server_response = self.get_request(url, req_options) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, workbook_item) return revisions @@ -912,47 +452,13 @@ 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) if revision_number is None: - url = f"{self.baseurl}/{workbook_id}/content" + url = "{0}/{1}/content".format(self.baseurl, workbook_id) else: - url = f"{self.baseurl}/{workbook_id}/revisions/{revision_number}/content" + url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, workbook_id, revision_number) if not include_extract: url += "?includeExtract=False" @@ -974,129 +480,37 @@ def download_revision( f.write(chunk) return_path = os.path.abspath(download_path) - logger.info(f"Downloaded workbook revision {revision_number} to {return_path} (ID: {workbook_id})") + logger.info( + "Downloaded workbook revision {0} to {1} (ID: {2})".format(revision_number, return_path, workbook_id) + ) return return_path @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]) self.delete_request(url) - logger.info(f"Deleted single workbook revision (ID: {workbook_id}) (Revision: {revision_number})") + logger.info("Deleted single workbook revision (ID: {0}) (Revision: {1})".format(workbook_id, revision_number)) # a convenience method @api(version="2.8") 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. - """ + ) -> List["AddResponse"]: # actually should return a task 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. - """ + def add_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> Set[str]: 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/filter.py b/tableauserverclient/server/filter.py index fd90e281f..b936ceb92 100644 --- a/tableauserverclient/server/filter.py +++ b/tableauserverclient/server/filter.py @@ -1,7 +1,7 @@ from .request_options import RequestOptions -class Filter: +class Filter(object): def __init__(self, field, operator, value): self.field = field self.operator = operator @@ -16,7 +16,7 @@ def __str__(self): # to [,] # so effectively, remove any spaces between "," and "'" and then remove all "'" value_string = value_string.replace(", '", ",'").replace("'", "") - return f"{self.field}:{self.operator}:{value_string}" + return "{0}:{1}:{2}".format(self.field, self.operator, value_string) @property def value(self): diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index e6d261b61..ca9d83872 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,7 +1,6 @@ import copy from functools import partial -from typing import Optional, Protocol, TypeVar, Union, runtime_checkable -from collections.abc import Iterable, Iterator +from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions @@ -12,12 +11,14 @@ @runtime_checkable class Endpoint(Protocol[T]): - def get(self, req_options: Optional[RequestOptions]) -> tuple[list[T], PaginationItem]: ... + def get(self, req_options: Optional[RequestOptions]) -> Tuple[List[T], PaginationItem]: + ... @runtime_checkable class CallableEndpoint(Protocol[T]): - def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> tuple[list[T], PaginationItem]: ... + def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> Tuple[List[T], PaginationItem]: + ... class Pager(Iterable[T]): @@ -26,7 +27,7 @@ class Pager(Iterable[T]): Supports all `RequestOptions` including starting on any page. Also used by models to load sub-models (users in a group, views in a workbook, etc) by passing a different endpoint. - Will loop over anything that returns (list[ModelItem], PaginationItem). + Will loop over anything that returns (List[ModelItem], PaginationItem). """ def __init__( diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 801ad4a13..bbca612e9 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,10 +1,8 @@ -from collections.abc import Iterable, Iterator, Sized +from collections.abc import Sized from itertools import count -from typing import Optional, Protocol, TYPE_CHECKING, TypeVar, overload -import sys +from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TYPE_CHECKING, TypeVar, overload from tableauserverclient.config import config from tableauserverclient.models.pagination_item import PaginationItem -from tableauserverclient.server.endpoint.exceptions import ServerResponseError from tableauserverclient.server.filter import Filter from tableauserverclient.server.request_options import RequestOptions from tableauserverclient.server.sort import Sort @@ -36,36 +34,10 @@ def to_camel_case(word: str) -> str: class QuerySet(Iterable[T], Sized): - """ - QuerySet is a class that allows easy filtering, sorting, and iterating over - many endpoints in TableauServerClient. It is designed to be used in a similar - way to Django QuerySets, but with a more limited feature set. - - QuerySet is an iterable, and can be used in for loops, list comprehensions, - and other places where iterables are expected. - - QuerySet is also Sized, and can be used in places where the length of the - QuerySet is needed. The length of the QuerySet is the total number of items - available in the QuerySet, not just the number of items that have been - fetched. If the endpoint does not return a total count of items, the length - of the QuerySet will be sys.maxsize. If there is no total count, the - QuerySet will continue to fetch items until there are no more items to - fetch. - - QuerySet is not re-entrant. It is not designed to be used in multiple places - at the same time. If you need to use a QuerySet in multiple places, you - should create a new QuerySet for each place you need to use it, convert it - to a list, or create a deep copy of the QuerySet. - - QuerySets are also indexable, and can be sliced. If you try to access an - index that has not been fetched, the QuerySet will fetch the page that - contains the item you are looking for. - """ - def __init__(self, model: "QuerysetEndpoint[T]", page_size: Optional[int] = None) -> None: self.model = model self.request_options = RequestOptions(pagesize=page_size or config.PAGE_SIZE) - self._result_cache: list[T] = [] + self._result_cache: List[T] = [] self._pagination_item = PaginationItem() def __iter__(self: Self) -> Iterator[T]: @@ -77,30 +49,19 @@ 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: - if e.code == "400006": - # If the endpoint does not support pagination, it will end - # 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 + self._fetch_all() yield from self._result_cache - # If the length of the QuerySet is unknown, continue fetching until - # the result cache is empty. - if (size := len(self)) == 0: - continue - if (page * self.page_size) >= size: + # Set result_cache to empty so the fetch will populate + if (page * self.page_size) >= len(self): return @overload - def __getitem__(self: Self, k: Slice) -> list[T]: ... + def __getitem__(self: Self, k: Slice) -> List[T]: + ... @overload - def __getitem__(self: Self, k: int) -> T: ... + def __getitem__(self: Self, k: int) -> T: + ... def __getitem__(self, k): page = self.page_number @@ -142,7 +103,6 @@ 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] @@ -154,16 +114,11 @@ def _fetch_all(self: Self) -> None: """ Retrieve the data and store result and pagination item in 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 - else: - self._result_cache = response - self._pagination_item = PaginationItem() + if not self._result_cache: + self._result_cache, self._pagination_item = self.model.get(self.request_options) def __len__(self: Self) -> int: - return sys.maxsize if self.total_available is None else self.total_available + return self.total_available @property def total_available(self: Self) -> int: @@ -173,16 +128,12 @@ def total_available(self: Self) -> int: @property def page_number(self: Self) -> int: self._fetch_all() - # If the PaginationItem is not returned from the endpoint, use the - # pagenumber from the RequestOptions. - return self._pagination_item.page_number or self.request_options.pagenumber + return self._pagination_item.page_number @property def page_size(self: Self) -> int: self._fetch_all() - # If the PaginationItem is not returned from the endpoint, use the - # pagesize from the RequestOptions. - return self._pagination_item.page_size or self.request_options.pagesize + return self._pagination_item.page_size def filter(self: Self, *invalid, page_size: Optional[int] = None, **kwargs) -> Self: if invalid: @@ -209,22 +160,22 @@ def paginate(self: Self, **kwargs) -> Self: return self @staticmethod - def _parse_shorthand_filter(key: str) -> tuple[str, str]: + def _parse_shorthand_filter(key: str) -> Tuple[str, str]: tokens = key.split("__", 1) if len(tokens) == 1: operator = RequestOptions.Operator.Equals else: operator = tokens[1] if operator not in RequestOptions.Operator.__dict__.values(): - raise ValueError(f"Operator `{operator}` is not valid.") + raise ValueError("Operator `{}` is not valid.".format(operator)) field = to_camel_case(tokens[0]) if field not in RequestOptions.Field.__dict__.values(): - raise ValueError(f"Field name `{field}` is not valid.") + raise ValueError("Field name `{}` is not valid.".format(field)) return (field, operator) @staticmethod - def _parse_shorthand_sort(key: str) -> tuple[str, str]: + def _parse_shorthand_sort(key: str) -> Tuple[str, str]: direction = RequestOptions.Direction.Asc if key.startswith("-"): direction = RequestOptions.Direction.Desc diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index f7bd139d7..96fa14680 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,6 +1,5 @@ import xml.etree.ElementTree as ET -from typing import Any, Callable, Optional, TypeVar, TYPE_CHECKING, Union -from collections.abc import Iterable +from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, TypeVar, TYPE_CHECKING, Union from typing_extensions import ParamSpec @@ -16,7 +15,7 @@ # this file could be largely replaced if we were willing to import the huge file from generateDS -def _add_multipart(parts: dict) -> tuple[Any, str]: +def _add_multipart(parts: Dict) -> Tuple[Any, str]: mime_multipart_parts = list() for name, (filename, data, content_type) in parts.items(): multipart_part = RequestField(name=name, data=data, filename=filename) @@ -81,7 +80,7 @@ def _add_credentials_element(parent_element, connection_credentials): credentials_element.attrib["oAuth"] = "true" -class AuthRequest: +class AuthRequest(object): def signin_req(self, auth_item): xml_request = ET.Element("tsRequest") @@ -105,7 +104,7 @@ def switch_req(self, site_content_url): return ET.tostring(xml_request) -class ColumnRequest: +class ColumnRequest(object): def update_req(self, column_item): xml_request = ET.Element("tsRequest") column_element = ET.SubElement(xml_request, "column") @@ -116,7 +115,7 @@ def update_req(self, column_item): return ET.tostring(xml_request) -class DataAlertRequest: +class DataAlertRequest(object): def add_user_to_alert(self, alert_item: "DataAlertItem", user_id: str) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -141,7 +140,7 @@ def update_req(self, alert_item: "DataAlertItem") -> bytes: return ET.tostring(xml_request) -class DatabaseRequest: +class DatabaseRequest(object): def update_req(self, database_item): xml_request = ET.Element("tsRequest") database_element = ET.SubElement(xml_request, "database") @@ -160,7 +159,7 @@ def update_req(self, database_item): return ET.tostring(xml_request) -class DatasourceRequest: +class DatasourceRequest(object): def _generate_xml(self, datasource_item: DatasourceItem, connection_credentials=None, connections=None): xml_request = ET.Element("tsRequest") datasource_element = ET.SubElement(xml_request, "datasource") @@ -245,7 +244,7 @@ def publish_req_chunked(self, datasource_item, connection_credentials=None, conn return _add_multipart(parts) -class DQWRequest: +class DQWRequest(object): def add_req(self, dqw_item): xml_request = ET.Element("tsRequest") dqw_element = ET.SubElement(xml_request, "dataQualityWarning") @@ -275,7 +274,7 @@ def update_req(self, dqw_item): return ET.tostring(xml_request) -class FavoriteRequest: +class FavoriteRequest(object): def add_request(self, id_: Optional[str], target_type: str, label: Optional[str]) -> bytes: """ @@ -330,7 +329,7 @@ def add_workbook_req(self, id_: Optional[str], name: Optional[str]) -> bytes: return self.add_request(id_, Resource.Workbook, name) -class FileuploadRequest: +class FileuploadRequest(object): def chunk_req(self, chunk): parts = { "request_payload": ("", "", "text/xml"), @@ -339,8 +338,8 @@ def chunk_req(self, chunk): return _add_multipart(parts) -class FlowRequest: - def _generate_xml(self, flow_item: "FlowItem", connections: Optional[list["ConnectionItem"]] = None) -> bytes: +class FlowRequest(object): + def _generate_xml(self, flow_item: "FlowItem", connections: Optional[List["ConnectionItem"]] = None) -> bytes: xml_request = ET.Element("tsRequest") flow_element = ET.SubElement(xml_request, "flow") if flow_item.name is not None: @@ -371,8 +370,8 @@ def publish_req( flow_item: "FlowItem", filename: str, file_contents: bytes, - connections: Optional[list["ConnectionItem"]] = None, - ) -> tuple[Any, str]: + connections: Optional[List["ConnectionItem"]] = None, + ) -> Tuple[Any, str]: xml_request = self._generate_xml(flow_item, connections) parts = { @@ -381,14 +380,14 @@ def publish_req( } return _add_multipart(parts) - def publish_req_chunked(self, flow_item, connections=None) -> tuple[Any, str]: + def publish_req_chunked(self, flow_item, connections=None) -> Tuple[Any, str]: xml_request = self._generate_xml(flow_item, connections) parts = {"request_payload": ("", xml_request, "text/xml")} return _add_multipart(parts) -class GroupRequest: +class GroupRequest(object): def add_user_req(self, user_id: str) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -478,7 +477,7 @@ def update_req( return ET.tostring(xml_request) -class PermissionRequest: +class PermissionRequest(object): def add_req(self, rules: Iterable[PermissionsRule]) -> bytes: xml_request = ET.Element("tsRequest") permissions_element = ET.SubElement(xml_request, "permissions") @@ -500,7 +499,7 @@ def _add_all_capabilities(self, capabilities_element, capabilities_map): capability_element.attrib["mode"] = mode -class ProjectRequest: +class ProjectRequest(object): def update_req(self, project_item: "ProjectItem") -> bytes: xml_request = ET.Element("tsRequest") project_element = ET.SubElement(xml_request, "project") @@ -531,7 +530,7 @@ def create_req(self, project_item: "ProjectItem") -> bytes: return ET.tostring(xml_request) -class ScheduleRequest: +class ScheduleRequest(object): def create_req(self, schedule_item): xml_request = ET.Element("tsRequest") schedule_element = ET.SubElement(xml_request, "schedule") @@ -610,7 +609,7 @@ def add_flow_req(self, id_: Optional[str], task_type: str = TaskItem.Type.RunFlo return self._add_to_req(id_, "flow", task_type) -class SiteRequest: +class SiteRequest(object): def update_req(self, site_item: "SiteItem", parent_srv: Optional["Server"] = None): xml_request = ET.Element("tsRequest") site_element = ET.SubElement(xml_request, "site") @@ -849,7 +848,7 @@ def set_versioned_flow_attributes(self, flows_all, flows_edit, flows_schedule, p warnings.warn("In version 3.10 and earlier there is only one option: FlowsEnabled") -class TableRequest: +class TableRequest(object): def update_req(self, table_item): xml_request = ET.Element("tsRequest") table_element = ET.SubElement(xml_request, "table") @@ -872,7 +871,7 @@ def update_req(self, table_item): content_types = Iterable[Union["ColumnItem", "DatabaseItem", "DatasourceItem", "FlowItem", "TableItem", "WorkbookItem"]] -class TagRequest: +class TagRequest(object): def add_req(self, tag_set): xml_request = ET.Element("tsRequest") tags_element = ET.SubElement(xml_request, "tags") @@ -882,7 +881,7 @@ def add_req(self, tag_set): return ET.tostring(xml_request) @_tsrequest_wrapped - def batch_create(self, element: ET.Element, tags: set[str], content: content_types) -> bytes: + def batch_create(self, element: ET.Element, tags: Set[str], content: content_types) -> bytes: tag_batch = ET.SubElement(element, "tagBatch") tags_element = ET.SubElement(tag_batch, "tags") for tag in tags: @@ -898,7 +897,7 @@ def batch_create(self, element: ET.Element, tags: set[str], content: content_typ return ET.tostring(element) -class UserRequest: +class UserRequest(object): def update_req(self, user_item: UserItem, password: Optional[str]) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -932,7 +931,7 @@ def add_req(self, user_item: UserItem) -> bytes: return ET.tostring(xml_request) -class WorkbookRequest: +class WorkbookRequest(object): def _generate_xml( self, workbook_item, @@ -996,9 +995,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.") @@ -1076,7 +1075,7 @@ def embedded_extract_req( datasource_element.attrib["id"] = id_ -class Connection: +class Connection(object): @_tsrequest_wrapped def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") -> None: connection_element = ET.SubElement(xml_request, "connection") @@ -1099,7 +1098,7 @@ def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") connection_element.attrib["queryTaggingEnabled"] = str(connection_item.query_tagging).lower() -class TaskRequest: +class TaskRequest(object): @_tsrequest_wrapped def run_req(self, xml_request: ET.Element, task_item: Any) -> None: # Send an empty tsRequest @@ -1138,7 +1137,7 @@ def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") return ET.tostring(xml_request) -class FlowTaskRequest: +class FlowTaskRequest(object): @_tsrequest_wrapped def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") -> bytes: flow_element = ET.SubElement(xml_request, "runFlow") @@ -1172,7 +1171,7 @@ def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") - return ET.tostring(xml_request) -class SubscriptionRequest: +class SubscriptionRequest(object): @_tsrequest_wrapped def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionItem") -> bytes: subscription_element = ET.SubElement(xml_request, "subscription") @@ -1236,13 +1235,13 @@ def update_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt return ET.tostring(xml_request) -class EmptyRequest: +class EmptyRequest(object): @_tsrequest_wrapped def empty_req(self, xml_request: ET.Element) -> None: pass -class WebhookRequest: +class WebhookRequest(object): @_tsrequest_wrapped def create_req(self, xml_request: ET.Element, webhook_item: "WebhookItem") -> bytes: webhook = ET.SubElement(xml_request, "webhook") @@ -1288,7 +1287,7 @@ def update_req(self, xml_request: ET.Element, metric_item: MetricItem) -> bytes: return ET.tostring(xml_request) -class CustomViewRequest: +class CustomViewRequest(object): @_tsrequest_wrapped def update_req(self, xml_request: ET.Element, custom_view_item: CustomViewItem): updating_element = ET.SubElement(xml_request, "customView") @@ -1416,7 +1415,7 @@ def publish(self, xml_request: ET.Element, virtual_connection: VirtualConnection return ET.tostring(xml_request) -class RequestFactory: +class RequestFactory(object): Auth = AuthRequest() Connection = Connection() Column = ColumnRequest() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index d79ac7f73..ddb45834d 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,5 +1,4 @@ import sys -from typing import Optional from typing_extensions import Self @@ -10,12 +9,12 @@ from tableauserverclient.helpers.logging import logger -class RequestOptionsBase: +class RequestOptionsBase(object): # This method is used if server api version is below 3.7 (2020.1) def apply_query_params(self, url): try: params = self.get_query_params() - params_list = [f"{k}={v}" for (k, v) in params.items()] + params_list = ["{}={}".format(k, v) for (k, v) in params.items()] logger.debug("Applying options to request: <%s(%s)>", self.__class__.__name__, ",".join(params_list)) @@ -23,52 +22,15 @@ def apply_query_params(self, url): url, existing_params = url.split("?") params_list.append(existing_params) - return "{}?{}".format(url, "&".join(params_list)) + return "{0}?{1}".format(url, "&".join(params_list)) except NotImplementedError: raise - -# 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 -""" + def get_query_params(self): + raise NotImplementedError() 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" @@ -79,7 +41,6 @@ class Operator: Has = "has" CaseInsensitiveEquals = "cieq" - # These are fields in the REST API class Field: Args = "args" AuthenticationType = "authenticationType" @@ -156,53 +117,60 @@ 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() -""" -These options can be used by methods that are fetching data exported from a specific content item -""" - - -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 + # This is private until we expand all of our parsers to handle the extra fields + self._all_fields = False - @property - def max_age(self) -> int: - return self._max_age + def page_size(self, page_size): + self.pagesize = page_size + return self - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value): - self._max_age = value + def page_number(self, page_number): + self.pagenumber = page_number + return self def get_query_params(self): params = {} - if self.max_age != -1: - params["maxAge"] = self.max_age - if self.language: - params["language"] = self.language - - self._append_view_filters(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 _FilterOptionsBase(RequestOptionsBase): + """Provide a basic implementation of adding view filters to the url""" + + def __init__(self): + self.view_filters = [] + self.view_parameters = [] + + def get_query_params(self): + raise NotImplementedError() + def vf(self, name: str, value: str) -> Self: - """Apply a filter based on a column within the view. - Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'""" + """Apply a filter to the view for a filter that is a normal column + within the view.""" self.view_filters.append((name, value)) return self def parameter(self, name: str, value: str) -> Self: - """Apply a filter based on a parameter within the workbook. - Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'""" + """Apply a filter based on a parameter within the workbook.""" self.view_parameters.append((name, value)) return self @@ -213,73 +181,82 @@ def _append_view_filters(self, params) -> None: params[name] = value -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 +class CSVRequestOptions(_FilterOptionsBase): + def __init__(self, maxage=-1): + super(CSVRequestOptions, self).__init__() + self.max_age = maxage @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) -> dict: - params = super().get_query_params() - - # 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") + def max_age(self): + return self._max_age - if self.viz_height is not None: - params["vizHeight"] = self.viz_height + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value): + self._max_age = value - if self.viz_width is not None: - params["vizWidth"] = self.viz_width + def get_query_params(self): + params = {} + if self.max_age != -1: + params["maxAge"] = self.max_age + self._append_view_filters(params) return params -class CSVRequestOptions(_DataExportOptions): - extension = "csv" +class ExcelRequestOptions(_FilterOptionsBase): + def __init__(self, maxage: int = -1) -> None: + super().__init__() + self.max_age = maxage + @property + def max_age(self) -> int: + return self._max_age + + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value: int) -> None: + self._max_age = value -class ExcelRequestOptions(_DataExportOptions): - extension = "xlsx" + def get_query_params(self): + params = {} + if self.max_age != -1: + params["maxAge"] = self.max_age + self._append_view_filters(params) + return params -class ImageRequestOptions(_ImagePDFCommonExportOptions): - extension = "png" +class ImageRequestOptions(_FilterOptionsBase): # 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, viz_height=None, viz_width=None): - super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width) + def __init__(self, imageresolution=None, maxage=-1): + super(ImageRequestOptions, self).__init__() 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 = super().get_query_params() + 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(_ImagePDFCommonExportOptions): +class PDFRequestOptions(_FilterOptionsBase): class PageType: A3 = "a3" A4 = "a4" @@ -301,16 +278,61 @@ class Orientation: Landscape = "landscape" def __init__(self, page_type=None, orientation=None, maxage=-1, viz_height=None, viz_width=None): - super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width) + super(PDFRequestOptions, self).__init__() 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) -> dict: - params = super().get_query_params() + def get_query_params(self): + 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 4eeefcaf9..e563a7138 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -58,64 +58,8 @@ default_server_version = "2.4" # first version that dropped the legacy auth endpoint -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 Server(object): 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" @@ -186,7 +130,7 @@ def validate_connection_settings(self): raise ValueError("Server connection settings not valid", req_ex) def __repr__(self): - return f"" + return "".format(self.baseurl, self.server_info.serverInfo) def add_http_options(self, options_dict: dict): try: @@ -198,7 +142,7 @@ def add_http_options(self, options_dict: dict): # expected errors on invalid input: # 'set' object has no attribute 'keys', 'list' object has no attribute 'keys' # TypeError: cannot convert dictionary update sequence element #0 to a sequence (input is a tuple) - raise ValueError(f"Invalid http options given: {options_dict}") + raise ValueError("Invalid http options given: {}".format(options_dict)) def clear_http_options(self): self._http_options = dict() @@ -232,15 +176,15 @@ def _determine_highest_version(self): old_version = self.version version = self.server_info.get().rest_api_version except ServerInfoEndpointNotFoundError as e: - logger.info(f"Could not get version info from server: {e.__class__}{e}") + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = self._get_legacy_version() except EndpointUnavailableError as e: - logger.info(f"Could not get version info from server: {e.__class__}{e}") + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = self._get_legacy_version() except Exception as e: - logger.info(f"Could not get version info from server: {e.__class__}{e}") + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = None - logger.info(f"versions: {version}, {old_version}") + logger.info("versions: {}, {}".format(version, old_version)) return version or old_version def use_server_version(self): @@ -257,12 +201,12 @@ def check_at_least_version(self, target: str): def assert_at_least_version(self, comparison: str, reason: str): if not self.check_at_least_version(comparison): - error = f"{reason} is not available in API version {self.version}. Requires {comparison}" + error = "{} is not available in API version {}. Requires {}".format(reason, self.version, comparison) raise EndpointUnavailableError(error) @property def baseurl(self): - return f"{self._server_address}/api/{str(self.version)}" + return "{0}/api/{1}".format(self._server_address, str(self.version)) @property def namespace(self): diff --git a/tableauserverclient/server/sort.py b/tableauserverclient/server/sort.py index 839a8c8db..2d6bc030a 100644 --- a/tableauserverclient/server/sort.py +++ b/tableauserverclient/server/sort.py @@ -1,7 +1,7 @@ -class Sort: +class Sort(object): def __init__(self, field, direction): self.field = field self.direction = direction def __str__(self): - return f"{self.field}:{self.direction}" + return "{0}:{1}".format(self.field, self.direction) diff --git a/test/_utils.py b/test/_utils.py index b4ee93bc3..8527aaf8c 100644 --- a/test/_utils.py +++ b/test/_utils.py @@ -1,6 +1,5 @@ import os.path import unittest -from xml.etree import ElementTree as ET from contextlib import contextmanager TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -19,19 +18,6 @@ def read_xml_assets(*args): return map(read_xml_asset, args) -def server_response_error_factory(code: str, summary: str, detail: str) -> str: - root = ET.Element("tsResponse") - error = ET.SubElement(root, "error") - error.attrib["code"] = code - - summary_element = ET.SubElement(error, "summary") - summary_element.text = summary - - detail_element = ET.SubElement(error, "detail") - detail_element.text = detail - return ET.tostring(root, encoding="utf-8").decode("utf-8") - - @contextmanager def mocked_time(): mock_time = 0 diff --git a/test/assets/flow_runs_get.xml b/test/assets/flow_runs_get.xml index 489e8ac63..bdce4cdfb 100644 --- a/test/assets/flow_runs_get.xml +++ b/test/assets/flow_runs_get.xml @@ -1,4 +1,5 @@ + - + \ No newline at end of file diff --git a/test/assets/server_info_wrong_site.html b/test/assets/server_info_wrong_site.html deleted file mode 100644 index e92daeb2d..000000000 --- a/test/assets/server_info_wrong_site.html +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - 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 48100ad88..eaf13481e 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.FailedSignInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, 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.FailedSignInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, 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.FailedSignInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, 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 6e863a863..80800c86b 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -18,8 +18,6 @@ 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" @@ -248,73 +246,3 @@ 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_dataalert.py b/test/test_dataalert.py index 6f6f1683c..d9e00a9db 100644 --- a/test/test_dataalert.py +++ b/test/test_dataalert.py @@ -108,5 +108,5 @@ def test_delete_user_from_alert(self) -> None: alert_id = "5ea59b45-e497-5673-8809-bfe213236f75" user_id = "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" with requests_mock.mock() as m: - m.delete(self.baseurl + f"/{alert_id}/users/{user_id}", status_code=204) + m.delete(self.baseurl + "/{0}/users/{1}".format(alert_id, user_id), status_code=204) self.server.data_alerts.delete_user_from_alert(alert_id, user_id) diff --git a/test/test_datasource.py b/test/test_datasource.py index 45d9ba9c9..624eb93e1 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -75,7 +75,7 @@ def test_get(self) -> None: self.assertEqual("Sample datasource", all_datasources[1].name) self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_datasources[1].project_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_datasources[1].owner_id) - self.assertEqual({"world", "indicators", "sample"}, all_datasources[1].tags) + self.assertEqual(set(["world", "indicators", "sample"]), all_datasources[1].tags) self.assertEqual("https://page.com", all_datasources[1].webpage_url) self.assertTrue(all_datasources[1].encrypt_extracts) self.assertFalse(all_datasources[1].has_extracts) @@ -110,7 +110,7 @@ def test_get_by_id(self) -> None: self.assertEqual("Sample datasource", single_datasource.name) self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_datasource.project_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_datasource.owner_id) - self.assertEqual({"world", "indicators", "sample"}, single_datasource.tags) + self.assertEqual(set(["world", "indicators", "sample"]), single_datasource.tags) self.assertEqual(TSC.DatasourceItem.AskDataEnablement.SiteDefault, single_datasource.ask_data_enablement) def test_update(self) -> None: @@ -488,7 +488,7 @@ def test_download_object(self) -> None: def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.tds" - disposition = f'name="tableau_workbook"; filename="{filename}"' + disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) with requests_mock.mock() as m: m.get( self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", @@ -659,7 +659,7 @@ def test_revisions(self) -> None: response_xml = read_xml_asset(REVISION_XML) with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{datasource.id}/revisions", text=response_xml) + m.get("{0}/{1}/revisions".format(self.baseurl, datasource.id), text=response_xml) self.server.datasources.populate_revisions(datasource) revisions = datasource.revisions @@ -687,7 +687,7 @@ def test_delete_revision(self) -> None: datasource._id = "06b944d2-959d-4604-9305-12323c95e70e" with requests_mock.mock() as m: - m.delete(f"{self.baseurl}/{datasource.id}/revisions/3") + m.delete("{0}/{1}/revisions/3".format(self.baseurl, datasource.id)) self.server.datasources.delete_revision(datasource.id, "3") def test_download_revision(self) -> None: diff --git a/test/test_endpoint.py b/test/test_endpoint.py index ff1ef0f72..8635af978 100644 --- a/test/test_endpoint.py +++ b/test/test_endpoint.py @@ -54,7 +54,7 @@ def test_get_request_stream(self) -> None: self.assertFalse(response._content_consumed) def test_binary_log_truncated(self): - class FakeResponse: + class FakeResponse(object): headers = {"Content-Type": "application/octet-stream"} content = b"\x1337" * 1000 status_code = 200 diff --git a/test/test_favorites.py b/test/test_favorites.py index 87332d70f..6f0be3b3c 100644 --- a/test/test_favorites.py +++ b/test/test_favorites.py @@ -28,7 +28,7 @@ def setUp(self): def test_get(self) -> None: response_xml = read_xml_asset(GET_FAVORITES_XML) with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{self.user.id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) self.server.favorites.get(self.user) self.assertIsNotNone(self.user._favorites) self.assertEqual(len(self.user.favorites["workbooks"]), 1) @@ -54,7 +54,7 @@ def test_add_favorite_workbook(self) -> None: workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" workbook.name = "Superstore" with requests_mock.mock() as m: - m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) + m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) self.server.favorites.add_favorite_workbook(self.user, workbook) def test_add_favorite_view(self) -> None: @@ -63,7 +63,7 @@ def test_add_favorite_view(self) -> None: view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" view._name = "ENDANGERED SAFARI" with requests_mock.mock() as m: - m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) + m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) self.server.favorites.add_favorite_view(self.user, view) def test_add_favorite_datasource(self) -> None: @@ -72,7 +72,7 @@ def test_add_favorite_datasource(self) -> None: datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" datasource.name = "SampleDS" with requests_mock.mock() as m: - m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) + m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) self.server.favorites.add_favorite_datasource(self.user, datasource) def test_add_favorite_project(self) -> None: @@ -82,7 +82,7 @@ def test_add_favorite_project(self) -> None: project = TSC.ProjectItem("Tableau") project._id = "1d0304cd-3796-429f-b815-7258370b9b74" with requests_mock.mock() as m: - m.put(f"{baseurl}/{self.user.id}", text=response_xml) + m.put("{0}/{1}".format(baseurl, self.user.id), text=response_xml) self.server.favorites.add_favorite_project(self.user, project) def test_delete_favorite_workbook(self) -> None: @@ -90,7 +90,7 @@ def test_delete_favorite_workbook(self) -> None: workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" workbook.name = "Superstore" with requests_mock.mock() as m: - m.delete(f"{self.baseurl}/{self.user.id}/workbooks/{workbook.id}") + m.delete("{0}/{1}/workbooks/{2}".format(self.baseurl, self.user.id, workbook.id)) self.server.favorites.delete_favorite_workbook(self.user, workbook) def test_delete_favorite_view(self) -> None: @@ -98,7 +98,7 @@ def test_delete_favorite_view(self) -> None: view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" view._name = "ENDANGERED SAFARI" with requests_mock.mock() as m: - m.delete(f"{self.baseurl}/{self.user.id}/views/{view.id}") + m.delete("{0}/{1}/views/{2}".format(self.baseurl, self.user.id, view.id)) self.server.favorites.delete_favorite_view(self.user, view) def test_delete_favorite_datasource(self) -> None: @@ -106,7 +106,7 @@ def test_delete_favorite_datasource(self) -> None: datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" datasource.name = "SampleDS" with requests_mock.mock() as m: - m.delete(f"{self.baseurl}/{self.user.id}/datasources/{datasource.id}") + m.delete("{0}/{1}/datasources/{2}".format(self.baseurl, self.user.id, datasource.id)) self.server.favorites.delete_favorite_datasource(self.user, datasource) def test_delete_favorite_project(self) -> None: @@ -115,5 +115,5 @@ def test_delete_favorite_project(self) -> None: project = TSC.ProjectItem("Tableau") project._id = "1d0304cd-3796-429f-b815-7258370b9b74" with requests_mock.mock() as m: - m.delete(f"{baseurl}/{self.user.id}/projects/{project.id}") + m.delete("{0}/{1}/projects/{2}".format(baseurl, self.user.id, project.id)) self.server.favorites.delete_favorite_project(self.user, project) diff --git a/test/test_filesys_helpers.py b/test/test_filesys_helpers.py index 0f3234d5d..4c8fb0f9f 100644 --- a/test/test_filesys_helpers.py +++ b/test/test_filesys_helpers.py @@ -37,7 +37,7 @@ def test_get_file_type_identifies_a_zip_file(self): with BytesIO() as file_object: with ZipFile(file_object, "w") as zf: with BytesIO() as stream: - stream.write(b"This is a zip file") + stream.write("This is a zip file".encode()) zf.writestr("dummy_file", stream.getbuffer()) file_object.seek(0) file_type = get_file_type(file_object) diff --git a/test/test_fileuploads.py b/test/test_fileuploads.py index 9567bc3ad..50a5ef48b 100644 --- a/test/test_fileuploads.py +++ b/test/test_fileuploads.py @@ -33,7 +33,7 @@ def setUp(self): self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/fileUploads" + self.baseurl = "{}/sites/{}/fileUploads".format(self.server.baseurl, self.server.site_id) def test_read_chunks_file_path(self): file_path = asset("SampleWB.twbx") @@ -57,7 +57,7 @@ def test_upload_chunks_file_path(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put(f"{self.baseurl}/{upload_id}", text=append_response_xml) + m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) actual = self.server.fileuploads.upload(file_path) self.assertEqual(upload_id, actual) @@ -72,7 +72,7 @@ def test_upload_chunks_file_object(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put(f"{self.baseurl}/{upload_id}", text=append_response_xml) + m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) actual = self.server.fileuploads.upload(file_content) self.assertEqual(upload_id, actual) diff --git a/test/test_flowruns.py b/test/test_flowruns.py index 8af2540dc..864c0d3cd 100644 --- a/test/test_flowruns.py +++ b/test/test_flowruns.py @@ -1,4 +1,3 @@ -import sys import unittest import requests_mock @@ -6,7 +5,7 @@ import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException -from ._utils import read_xml_asset, mocked_time, server_response_error_factory +from ._utils import read_xml_asset, mocked_time GET_XML = "flow_runs_get.xml" GET_BY_ID_XML = "flow_runs_get_by_id.xml" @@ -29,8 +28,9 @@ def test_get(self) -> None: response_xml = read_xml_asset(GET_XML) with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) - all_flow_runs = self.server.flow_runs.get() + all_flow_runs, pagination_item = self.server.flow_runs.get() + self.assertEqual(2, pagination_item.total_available) self.assertEqual("cc2e652d-4a9b-4476-8c93-b238c45db968", all_flow_runs[0].id) self.assertEqual("2021-02-11T01:42:55Z", format_datetime(all_flow_runs[0].started_at)) self.assertEqual("2021-02-11T01:57:38Z", format_datetime(all_flow_runs[0].completed_at)) @@ -75,7 +75,7 @@ def test_wait_for_job_finished(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) flow_run_id = "cc2e652d-4a9b-4476-8c93-b238c45db968" with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) flow_run = self.server.flow_runs.wait_for_job(flow_run_id) self.assertEqual(flow_run_id, flow_run.id) @@ -86,7 +86,7 @@ def test_wait_for_job_failed(self) -> None: response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) flow_run_id = "c2b35d5a-e130-471a-aec8-7bc5435fe0e7" with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) with self.assertRaises(FlowRunFailedException): self.server.flow_runs.wait_for_job(flow_run_id) @@ -95,17 +95,6 @@ def test_wait_for_job_timeout(self) -> None: response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) flow_run_id = "71afc22c-9c06-40be-8d0f-4c4166d29e6c" with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) with self.assertRaises(TimeoutError): self.server.flow_runs.wait_for_job(flow_run_id, timeout=30) - - def test_queryset(self) -> None: - response_xml = read_xml_asset(GET_XML) - error_response = server_response_error_factory( - "400006", "Bad Request", "0xB4EAB088 : The start index '9900' is greater than or equal to the total count.)" - ) - with requests_mock.mock() as m: - m.get(f"{self.baseurl}?pageNumber=1", text=response_xml) - m.get(f"{self.baseurl}?pageNumber=2", text=error_response) - queryset = self.server.flow_runs.all() - assert len(queryset) == sys.maxsize diff --git a/test/test_flowtask.py b/test/test_flowtask.py index 2d9f7c7bd..034066e64 100644 --- a/test/test_flowtask.py +++ b/test/test_flowtask.py @@ -40,7 +40,7 @@ def test_create_flow_task(self): with open(GET_XML_CREATE_FLOW_TASK_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(f"{self.baseurl}", text=response_xml) + m.post("{}".format(self.baseurl), text=response_xml) create_response_content = self.server.flow_tasks.create(task).decode("utf-8") self.assertTrue("schedule_id" in create_response_content) diff --git a/test/test_group.py b/test/test_group.py index 41b5992be..fc9c75a6d 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -1,3 +1,4 @@ +# encoding=utf-8 from pathlib import Path import unittest import os diff --git a/test/test_job.py b/test/test_job.py index 20b238764..d86397086 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -51,7 +51,7 @@ def test_get_by_id(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{job_id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) job = self.server.jobs.get_by_id(job_id) updated_at = datetime(2020, 5, 13, 20, 25, 18, tzinfo=utc) @@ -81,7 +81,7 @@ def test_wait_for_job_finished(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{job_id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) job = self.server.jobs.wait_for_job(job_id) self.assertEqual(job_id, job.id) @@ -92,7 +92,7 @@ def test_wait_for_job_failed(self) -> None: response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{job_id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) with self.assertRaises(JobFailedException): self.server.jobs.wait_for_job(job_id) @@ -101,7 +101,7 @@ def test_wait_for_job_timeout(self) -> None: response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{job_id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) with self.assertRaises(TimeoutError): self.server.jobs.wait_for_job(job_id, timeout=30) diff --git a/test/test_pager.py b/test/test_pager.py index 1836095bb..c30352809 100644 --- a/test/test_pager.py +++ b/test/test_pager.py @@ -1,7 +1,6 @@ import contextlib import os import unittest -import xml.etree.ElementTree as ET import requests_mock @@ -123,14 +122,3 @@ 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_project.py b/test/test_project.py index 430db84b2..e05785f86 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -241,9 +241,9 @@ def test_delete_permission(self) -> None: rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) - endpoint = f"{single_project._id}/permissions/groups/{single_group._id}" - m.delete(f"{self.baseurl}/{endpoint}/Read/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/Write/Allow", status_code=204) + endpoint = "{}/permissions/groups/{}".format(single_project._id, single_group._id) + m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/Write/Allow".format(self.baseurl, endpoint), status_code=204) self.server.projects.delete_permission(item=single_project, rules=rules) def test_delete_workbook_default_permission(self) -> None: @@ -287,19 +287,19 @@ def test_delete_workbook_default_permission(self) -> None: rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) - endpoint = f"{single_project._id}/default-permissions/workbooks/groups/{single_group._id}" - m.delete(f"{self.baseurl}/{endpoint}/Read/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ExportImage/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ExportData/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ViewComments/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/AddComment/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/Filter/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ViewUnderlyingData/Deny", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ShareView/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/WebAuthoring/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/Write/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ExportXml/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ChangeHierarchy/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/Delete/Deny", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ChangePermissions/Allow", status_code=204) + endpoint = "{}/default-permissions/workbooks/groups/{}".format(single_project._id, single_group._id) + m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ExportImage/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ExportData/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ViewComments/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/AddComment/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/Filter/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ViewUnderlyingData/Deny".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ShareView/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/WebAuthoring/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/Write/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ExportXml/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ChangeHierarchy/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/Delete/Deny".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ChangePermissions/Allow".format(self.baseurl, endpoint), status_code=204) self.server.projects.delete_workbook_default_permissions(item=single_project, rule=rules) diff --git a/test/test_regression_tests.py b/test/test_regression_tests.py index 62e301591..772704f69 100644 --- a/test/test_regression_tests.py +++ b/test/test_regression_tests.py @@ -1,5 +1,9 @@ import unittest -from unittest import mock + +try: + from unittest import mock +except ImportError: + import mock # type: ignore[no-redef] import tableauserverclient.server.request_factory as factory from tableauserverclient.helpers.strings import redact_xml diff --git a/test/test_request_option.py b/test/test_request_option.py index 7405189a3..e48f8510a 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -31,7 +31,7 @@ def setUp(self) -> None: self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = f"{self.server.sites.baseurl}/{self.server._site_id}" + self.baseurl = "{0}/{1}".format(self.server.sites.baseurl, self.server._site_id) def test_pagination(self) -> None: with open(PAGINATION_XML, "rb") as f: @@ -112,9 +112,9 @@ def test_filter_tags_in(self) -> None: matching_workbooks, pagination_item = self.server.workbooks.get(req_option) self.assertEqual(3, pagination_item.total_available) - self.assertEqual({"weather"}, matching_workbooks[0].tags) - self.assertEqual({"safari"}, matching_workbooks[1].tags) - self.assertEqual({"sample"}, matching_workbooks[2].tags) + self.assertEqual(set(["weather"]), matching_workbooks[0].tags) + self.assertEqual(set(["safari"]), matching_workbooks[1].tags) + self.assertEqual(set(["sample"]), matching_workbooks[2].tags) # check if filtered projects with spaces & special characters # get correctly returned @@ -148,9 +148,9 @@ def test_filter_tags_in_shorthand(self) -> None: matching_workbooks = self.server.workbooks.filter(tags__in=["sample", "safari", "weather"]) self.assertEqual(3, matching_workbooks.total_available) - self.assertEqual({"weather"}, matching_workbooks[0].tags) - self.assertEqual({"safari"}, matching_workbooks[1].tags) - self.assertEqual({"sample"}, matching_workbooks[2].tags) + self.assertEqual(set(["weather"]), matching_workbooks[0].tags) + self.assertEqual(set(["safari"]), matching_workbooks[1].tags) + self.assertEqual(set(["sample"]), matching_workbooks[2].tags) def test_invalid_shorthand_option(self) -> None: with self.assertRaises(ValueError): @@ -358,13 +358,3 @@ 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_schedule.py b/test/test_schedule.py index b072522a4..0377295d7 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -106,7 +106,7 @@ def test_get_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -120,7 +120,7 @@ def test_get_hourly_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -135,7 +135,7 @@ def test_get_daily_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -150,7 +150,7 @@ def test_get_monthly_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -165,7 +165,7 @@ def test_get_monthly_by_id_2(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "8c5caf33-6223-4724-83c3-ccdc1e730a07" - baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -347,7 +347,7 @@ def test_update_after_get(self) -> None: def test_add_workbook(self) -> None: self.server.version = "2.8" - baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" + baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: workbook_response = f.read().decode("utf-8") @@ -362,7 +362,7 @@ def test_add_workbook(self) -> None: def test_add_workbook_with_warnings(self) -> None: self.server.version = "2.8" - baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" + baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: workbook_response = f.read().decode("utf-8") @@ -378,7 +378,7 @@ def test_add_workbook_with_warnings(self) -> None: def test_add_datasource(self) -> None: self.server.version = "2.8" - baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" + baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) with open(DATASOURCE_GET_BY_ID_XML, "rb") as f: datasource_response = f.read().decode("utf-8") @@ -393,7 +393,7 @@ def test_add_datasource(self) -> None: def test_add_flow(self) -> None: self.server.version = "3.3" - baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" + baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) with open(FLOW_GET_BY_ID_XML, "rb") as f: flow_response = f.read().decode("utf-8") diff --git a/test/test_server_info.py b/test/test_server_info.py index fa1472c9a..1cf190ecd 100644 --- a/test/test_server_info.py +++ b/test/test_server_info.py @@ -4,7 +4,6 @@ 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") @@ -12,7 +11,6 @@ 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): @@ -65,11 +63,3 @@ 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() diff --git a/test/test_site_model.py b/test/test_site_model.py index 60ad9c5e5..f62eb66f0 100644 --- a/test/test_site_model.py +++ b/test/test_site_model.py @@ -1,3 +1,5 @@ +# coding=utf-8 + import unittest import tableauserverclient as TSC diff --git a/test/test_tagging.py b/test/test_tagging.py index 23dffebfb..0184af415 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -1,6 +1,6 @@ from contextlib import ExitStack import re -from collections.abc import Iterable +from typing import Iterable import uuid from xml.etree import ElementTree as ET @@ -172,7 +172,7 @@ def test_update_tags(get_server, endpoint_type, item, tags) -> None: if isinstance(item, str): stack.enter_context(pytest.raises((ValueError, NotImplementedError))) elif hasattr(item, "_initial_tags"): - initial_tags = {"x", "y", "z"} + initial_tags = set(["x", "y", "z"]) item._initial_tags = initial_tags add_tags_xml = add_tag_xml_response_factory(tags - initial_tags) delete_tags_xml = add_tag_xml_response_factory(initial_tags - tags) diff --git a/test/test_task.py b/test/test_task.py index 2d724b879..53da7c160 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -119,7 +119,7 @@ def test_get_materializeviews_tasks(self): with open(GET_XML_DATAACCELERATION_TASK, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(f"{self.server.tasks.baseurl}/{TaskItem.Type.DataAcceleration}", text=response_xml) + m.get("{}/{}".format(self.server.tasks.baseurl, TaskItem.Type.DataAcceleration), text=response_xml) all_tasks, pagination_item = self.server.tasks.get(task_type=TaskItem.Type.DataAcceleration) task = all_tasks[0] @@ -145,7 +145,7 @@ def test_get_by_id(self): response_xml = f.read().decode("utf-8") task_id = "f84901ac-72ad-4f9b-a87e-7a3500402ad6" with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{task_id}", text=response_xml) + m.get("{}/{}".format(self.baseurl, task_id), text=response_xml) task = self.server.tasks.get_by_id(task_id) self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) @@ -159,7 +159,7 @@ def test_run_now(self): with open(GET_XML_RUN_NOW_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(f"{self.baseurl}/{task_id}/runNow", text=response_xml) + m.post("{}/{}/runNow".format(self.baseurl, task_id), text=response_xml) job_response_content = self.server.tasks.run(task).decode("utf-8") self.assertTrue("7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6" in job_response_content) @@ -181,7 +181,7 @@ def test_create_extract_task(self): with open(GET_XML_CREATE_TASK_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(f"{self.baseurl}", text=response_xml) + m.post("{}".format(self.baseurl), text=response_xml) create_response_content = self.server.tasks.create(task).decode("utf-8") self.assertTrue("task_id" in create_response_content) diff --git a/test/test_user.py b/test/test_user.py index a46624845..1f5eba57f 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,5 +1,8 @@ +import io import os import unittest +from typing import List +from unittest.mock import MagicMock import requests_mock @@ -160,7 +163,7 @@ def test_populate_workbooks(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", workbook_list[0].project_id) self.assertEqual("default", workbook_list[0].project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", workbook_list[0].owner_id) - self.assertEqual({"Safari", "Sample"}, workbook_list[0].tags) + self.assertEqual(set(["Safari", "Sample"]), workbook_list[0].tags) def test_populate_workbooks_missing_id(self) -> None: single_user = TSC.UserItem("test", "Interactor") @@ -173,7 +176,7 @@ def test_populate_favorites(self) -> None: with open(GET_FAVORITES_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(f"{baseurl}/{single_user.id}", text=response_xml) + m.get("{0}/{1}".format(baseurl, single_user.id), text=response_xml) self.server.users.populate_favorites(single_user) self.assertIsNotNone(single_user._favorites) self.assertEqual(len(single_user.favorites["workbooks"]), 1) diff --git a/test/test_user_model.py b/test/test_user_model.py index a8a2c51cb..d0997b9ff 100644 --- a/test/test_user_model.py +++ b/test/test_user_model.py @@ -1,6 +1,7 @@ import logging import unittest from unittest.mock import * +from typing import List import io import pytest @@ -106,7 +107,7 @@ def test_validate_user_detail_standard(self): TSC.UserItem.CSVImport.create_user_from_line(test_line) # for file handling - def _mock_file_content(self, content: list[str]) -> io.TextIOWrapper: + def _mock_file_content(self, content: List[str]) -> io.TextIOWrapper: # the empty string represents EOF # the tests run through the file twice, first to validate then to fetch mock = MagicMock(io.TextIOWrapper) @@ -118,10 +119,10 @@ def _mock_file_content(self, content: list[str]) -> io.TextIOWrapper: def test_validate_import_file(self): test_data = self._mock_file_content(UserDataTest.valid_import_content) valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) - assert valid == 2, f"Expected two lines to be parsed, got {valid}" - assert invalid == [], f"Expected no failures, got {invalid}" + assert valid == 2, "Expected two lines to be parsed, got {}".format(valid) + assert invalid == [], "Expected no failures, got {}".format(invalid) def test_validate_usernames_file(self): test_data = self._mock_file_content(UserDataTest.usernames) valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) - assert valid == 5, f"Exactly 5 of the lines were valid, counted {valid + invalid}" + assert valid == 5, "Exactly 5 of the lines were valid, counted {}".format(valid + invalid) diff --git a/test/test_view.py b/test/test_view.py index a89a6d235..1c667a4c3 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -49,7 +49,7 @@ def test_get(self) -> None: self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", all_views[0].workbook_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[0].owner_id) self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", all_views[0].project_id) - self.assertEqual({"tag1", "tag2"}, all_views[0].tags) + self.assertEqual(set(["tag1", "tag2"]), all_views[0].tags) self.assertIsNone(all_views[0].created_at) self.assertIsNone(all_views[0].updated_at) self.assertIsNone(all_views[0].sheet_type) @@ -77,7 +77,7 @@ def test_get_by_id(self) -> None: self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", view.workbook_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", view.owner_id) self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", view.project_id) - self.assertEqual({"tag1", "tag2"}, view.tags) + self.assertEqual(set(["tag1", "tag2"]), view.tags) self.assertEqual("2002-05-30T09:00:00Z", format_datetime(view.created_at)) self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at)) self.assertEqual("story", view.sheet_type) @@ -95,7 +95,7 @@ def test_get_by_id_usage(self) -> None: self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", view.workbook_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", view.owner_id) self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", view.project_id) - self.assertEqual({"tag1", "tag2"}, view.tags) + self.assertEqual(set(["tag1", "tag2"]), view.tags) self.assertEqual("2002-05-30T09:00:00Z", format_datetime(view.created_at)) self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at)) self.assertEqual("story", view.sheet_type) diff --git a/test/test_view_acceleration.py b/test/test_view_acceleration.py index 766831b0a..6f94f0c10 100644 --- a/test/test_view_acceleration.py +++ b/test/test_view_acceleration.py @@ -42,7 +42,7 @@ def test_get_by_id(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_workbook.project_id) self.assertEqual("default", single_workbook.project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id) - self.assertEqual({"Safari", "Sample"}, single_workbook.tags) + self.assertEqual(set(["Safari", "Sample"]), single_workbook.tags) self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_workbook.views[0].id) self.assertEqual("ENDANGERED SAFARI", single_workbook.views[0].name) self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) diff --git a/test/test_workbook.py b/test/test_workbook.py index 1a6b3192f..950118dc0 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -83,7 +83,7 @@ def test_get(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_workbooks[1].project_id) self.assertEqual("default", all_workbooks[1].project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_workbooks[1].owner_id) - self.assertEqual({"Safari", "Sample"}, all_workbooks[1].tags) + self.assertEqual(set(["Safari", "Sample"]), all_workbooks[1].tags) def test_get_ignore_invalid_date(self) -> None: with open(GET_INVALID_DATE_XML, "rb") as f: @@ -127,7 +127,7 @@ def test_get_by_id(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_workbook.project_id) self.assertEqual("default", single_workbook.project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id) - self.assertEqual({"Safari", "Sample"}, single_workbook.tags) + self.assertEqual(set(["Safari", "Sample"]), single_workbook.tags) self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_workbook.views[0].id) self.assertEqual("ENDANGERED SAFARI", single_workbook.views[0].name) self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) @@ -152,7 +152,7 @@ def test_get_by_id_personal(self) -> None: self.assertTrue(single_workbook.project_id) self.assertIsNone(single_workbook.project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id) - self.assertEqual({"Safari", "Sample"}, single_workbook.tags) + self.assertEqual(set(["Safari", "Sample"]), single_workbook.tags) self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_workbook.views[0].id) self.assertEqual("ENDANGERED SAFARI", single_workbook.views[0].name) self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) @@ -277,7 +277,7 @@ def test_download_object(self) -> None: def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.twbx" - disposition = f'name="tableau_workbook"; filename="{filename}"' + disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) with requests_mock.mock() as m: m.get( self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", @@ -817,7 +817,7 @@ def test_revisions(self) -> None: with open(REVISION_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{workbook.id}/revisions", text=response_xml) + m.get("{0}/{1}/revisions".format(self.baseurl, workbook.id), text=response_xml) self.server.workbooks.populate_revisions(workbook) revisions = workbook.revisions @@ -846,7 +846,7 @@ def test_delete_revision(self) -> None: workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" with requests_mock.mock() as m: - m.delete(f"{self.baseurl}/{workbook.id}/revisions/3") + m.delete("{0}/{1}/revisions/3".format(self.baseurl, workbook.id)) self.server.workbooks.delete_revision(workbook.id, "3") def test_download_revision(self) -> None: diff --git a/versioneer.py b/versioneer.py index cce899f58..86c240e13 100644 --- a/versioneer.py +++ b/versioneer.py @@ -276,6 +276,7 @@ """ +from __future__ import print_function try: import configparser @@ -327,7 +328,7 @@ def get_root(): me_dir = os.path.normcase(os.path.splitext(me)[0]) vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0]) if me_dir != vsr_dir: - print(f"Warning: build in {os.path.dirname(me)} is using versioneer.py from {versioneer_py}") + print("Warning: build in %s is using versioneer.py from %s" % (os.path.dirname(me), versioneer_py)) except NameError: pass return root @@ -341,7 +342,7 @@ def get_config_from_root(root): # the top of versioneer.py for instructions on writing your setup.cfg . setup_cfg = os.path.join(root, "setup.cfg") parser = configparser.SafeConfigParser() - with open(setup_cfg) as f: + with open(setup_cfg, "r") as f: parser.readfp(f) VCS = parser.get("versioneer", "VCS") # mandatory @@ -397,7 +398,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= [c] + args, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=(subprocess.PIPE if hide_stderr else None) ) break - except OSError: + except EnvironmentError: e = sys.exc_info()[1] if e.errno == errno.ENOENT: continue @@ -407,7 +408,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= return None, None else: if verbose: - print(f"unable to find command, tried {commands}" + print("unable to find command, tried %s" % (commands,)) return None, None stdout = p.communicate()[0].strip() if sys.version_info[0] >= 3: @@ -422,7 +423,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= LONG_VERSION_PY[ "git" -] = r''' +] = ''' # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (built by setup.py sdist) and build @@ -954,7 +955,7 @@ def git_get_keywords(versionfile_abs): # _version.py. keywords = {} try: - f = open(versionfile_abs) + f = open(versionfile_abs, "r") for line in f.readlines(): if line.strip().startswith("git_refnames ="): mo = re.search(r'=\s*"(.*)"', line) @@ -969,7 +970,7 @@ def git_get_keywords(versionfile_abs): if mo: keywords["date"] = mo.group(1) f.close() - except OSError: + except EnvironmentError: pass return keywords @@ -993,11 +994,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): if verbose: print("keywords are unexpanded, not using") raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = {r.strip() for r in refnames.strip("()").split(",")} + refs = set([r.strip() for r in refnames.strip("()").split(",")]) # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} + tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -1006,7 +1007,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = {r for r in refs if re.search(r"\d", r)} + tags = set([r for r in refs if re.search(r"\d", r)]) if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -1099,7 +1100,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = f"tag '{full_tag}' doesn't start with prefix '{tag_prefix}'" + pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % (full_tag, tag_prefix) return pieces pieces["closest-tag"] = full_tag[len(tag_prefix) :] @@ -1144,13 +1145,13 @@ def do_vcs_install(manifest_in, versionfile_source, ipy): files.append(versioneer_file) present = False try: - f = open(".gitattributes") + f = open(".gitattributes", "r") for line in f.readlines(): if line.strip().startswith(versionfile_source): if "export-subst" in line.strip().split()[1:]: present = True f.close() - except OSError: + except EnvironmentError: pass if not present: f = open(".gitattributes", "a+") @@ -1184,7 +1185,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): root = os.path.dirname(root) # up a level if verbose: - print(f"Tried directories {rootdirs!s} but none started with prefix {parentdir_prefix}") + print("Tried directories %s but none started with prefix %s" % (str(rootdirs), parentdir_prefix)) raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @@ -1211,7 +1212,7 @@ def versions_from_file(filename): try: with open(filename) as f: contents = f.read() - except OSError: + except EnvironmentError: raise NotThisMethod("unable to read _version.py") mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", contents, re.M | re.S) if not mo: @@ -1228,7 +1229,7 @@ def write_to_version_file(filename, versions): with open(filename, "w") as f: f.write(SHORT_VERSION_PY % contents) - print(f"set {filename} to '{versions['version']}'") + print("set %s to '%s'" % (filename, versions["version"])) def plus_or_dot(pieces): @@ -1451,7 +1452,7 @@ def get_versions(verbose=False): try: ver = versions_from_file(versionfile_abs) if verbose: - print(f"got version from file {versionfile_abs} {ver}") + print("got version from file %s %s" % (versionfile_abs, ver)) return ver except NotThisMethod: pass @@ -1722,7 +1723,7 @@ def do_setup(): root = get_root() try: cfg = get_config_from_root(root) - except (OSError, configparser.NoSectionError, configparser.NoOptionError) as e: + except (EnvironmentError, configparser.NoSectionError, configparser.NoOptionError) as e: if isinstance(e, (EnvironmentError, configparser.NoSectionError)): print("Adding sample versioneer config to setup.cfg", file=sys.stderr) with open(os.path.join(root, "setup.cfg"), "a") as f: @@ -1747,9 +1748,9 @@ def do_setup(): ipy = os.path.join(os.path.dirname(cfg.versionfile_source), "__init__.py") if os.path.exists(ipy): try: - with open(ipy) as f: + with open(ipy, "r") as f: old = f.read() - except OSError: + except EnvironmentError: old = "" if INIT_PY_SNIPPET not in old: print(" appending to %s" % ipy) @@ -1768,12 +1769,12 @@ def do_setup(): manifest_in = os.path.join(root, "MANIFEST.in") simple_includes = set() try: - with open(manifest_in) as f: + with open(manifest_in, "r") as f: for line in f: if line.startswith("include "): for include in line.split()[1:]: simple_includes.add(include) - except OSError: + except EnvironmentError: pass # That doesn't cover everything MANIFEST.in can do # (http://docs.python.org/2/distutils/sourcedist.html#commands), so @@ -1804,7 +1805,7 @@ def scan_setup_py(): found = set() setters = False errors = 0 - with open("setup.py") as f: + with open("setup.py", "r") as f: for line in f.readlines(): if "import versioneer" in line: found.add("import")