From 60a3a2fef9880df686b8dd374677b54f47687faa Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Wed, 18 Sep 2024 23:46:43 -0700 Subject: [PATCH 01/28] Draft: Make urllib3 dependency more flexible (#1468) Make urllib3 dependency more flexible Per discussion in #1445 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3bf47ea2..b0428ec0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ 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 + 'urllib3>=2.2.2,<3', 'typing_extensions>=4.0.1', ] requires-python = ">=3.7" From ac8dccd321e669114b7665c1afa4759031801778 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 19 Sep 2024 01:47:15 -0500 Subject: [PATCH 02/28] chore(versions): Upgrade minimum python version (#1465) * chore(versions): Upgrade minimum python version As of October, 2024, Python 3.8 is out of support. Upgrading syntax to target Python 3.9. Adds builds for Python 3.13. --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .github/workflows/run-tests.yml | 2 +- pyproject.toml | 10 +- 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 | 17 +-- samples/explore_favorites.py | 10 +- samples/explore_site.py | 2 +- samples/explore_webhooks.py | 4 +- samples/explore_workbook.py | 33 +++--- samples/export.py | 6 +- samples/extracts.py | 2 +- samples/filter_sort_groups.py | 2 +- 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 | 2 +- samples/login.py | 4 +- samples/move_workbook_sites.py | 8 +- samples/pagination_sample.py | 8 +- samples/publish_datasource.py | 4 +- samples/publish_workbook.py | 4 +- samples/query_permissions.py | 8 +- samples/refresh_tasks.py | 4 +- samples/update_workbook_data_acceleration.py | 2 +- .../update_workbook_data_freshness_policy.py | 2 +- tableauserverclient/_version.py | 18 +-- tableauserverclient/models/column_item.py | 2 +- .../models/connection_credentials.py | 2 +- tableauserverclient/models/connection_item.py | 12 +- .../models/custom_view_item.py | 10 +- .../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 | 8 +- 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 | 20 ++-- tableauserverclient/models/project_item.py | 10 +- .../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 | 6 +- tableauserverclient/models/site_item.py | 6 +- .../models/subscription_item.py | 6 +- tableauserverclient/models/table_item.py | 2 +- tableauserverclient/models/tableau_auth.py | 10 +- tableauserverclient/models/tableau_types.py | 2 +- tableauserverclient/models/tag_item.py | 7 +- tableauserverclient/models/task_item.py | 8 +- tableauserverclient/models/user_item.py | 34 +++--- tableauserverclient/models/view_item.py | 21 ++-- .../models/virtual_connection_item.py | 11 +- tableauserverclient/models/webhook_item.py | 12 +- tableauserverclient/models/workbook_item.py | 24 ++-- tableauserverclient/namespace.py | 2 +- .../server/endpoint/auth_endpoint.py | 16 +-- .../server/endpoint/custom_views_endpoint.py | 24 ++-- .../data_acceleration_report_endpoint.py | 4 +- .../server/endpoint/data_alert_endpoint.py | 28 ++--- .../server/endpoint/databases_endpoint.py | 25 ++-- .../server/endpoint/datasources_endpoint.py | 97 ++++++++------- .../endpoint/default_permissions_endpoint.py | 27 +++-- .../server/endpoint/dqw_endpoint.py | 18 +-- .../server/endpoint/endpoint.py | 35 +++--- .../server/endpoint/exceptions.py | 6 +- .../server/endpoint/favorites_endpoint.py | 62 +++++----- .../server/endpoint/fileuploads_endpoint.py | 20 ++-- .../server/endpoint/flow_runs_endpoint.py | 18 +-- .../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 | 20 ++-- .../server/endpoint/resource_tagger.py | 27 ++--- .../server/endpoint/schedules_endpoint.py | 32 ++--- .../server/endpoint/server_info_endpoint.py | 4 +- .../server/endpoint/sites_endpoint.py | 34 +++--- .../server/endpoint/subscriptions_endpoint.py | 20 ++-- .../server/endpoint/tables_endpoint.py | 29 ++--- .../server/endpoint/tasks_endpoint.py | 16 +-- .../server/endpoint/users_endpoint.py | 38 +++--- .../server/endpoint/views_endpoint.py | 37 +++--- .../endpoint/virtual_connections_endpoint.py | 11 +- .../server/endpoint/webhooks_endpoint.py | 22 ++-- .../server/endpoint/workbooks_endpoint.py | 110 ++++++++---------- tableauserverclient/server/filter.py | 4 +- tableauserverclient/server/pager.py | 11 +- tableauserverclient/server/query.py | 19 ++- tableauserverclient/server/request_factory.py | 73 ++++++------ tableauserverclient/server/request_options.py | 12 +- tableauserverclient/server/server.py | 18 +-- tableauserverclient/server/sort.py | 4 +- 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 | 6 +- test/test_flowtask.py | 2 +- test/test_group.py | 1 - test/test_job.py | 8 +- test/test_project.py | 36 +++--- test/test_regression_tests.py | 6 +- test/test_request_option.py | 14 +-- test/test_schedule.py | 16 +-- 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 ++++---- 136 files changed, 948 insertions(+), 990 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index d7053958..7e1533ee 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,7 +13,7 @@ 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-dev'] runs-on: ${{ matrix.os }} diff --git a/pyproject.toml b/pyproject.toml index b0428ec0..cc3bf8fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,26 +18,26 @@ dependencies = [ '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 diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py index 5a450e8a..d26d009e 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 f4c6a9ca..aca3e895 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 1fc649f8..d775902a 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 dee08857..c23a2ece 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 fb45cb45..877c5f08 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -54,13 +54,13 @@ def main(): 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 +69,15 @@ 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]) # 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 243e9195..364e078c 100644 --- a/samples/explore_favorites.py +++ b/samples/explore_favorites.py @@ -39,7 +39,7 @@ 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) @@ -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 a2274f1a..eb9eba0d 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 77802b1d..f25c4184 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 57f88aa0..f51639ab 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 f2783fa6..815ec8b5 100644 --- a/samples/export.py +++ b/samples/export.py @@ -60,10 +60,10 @@ def main(): 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. @@ -83,7 +83,7 @@ def main(): if args.file: filename = args.file else: - filename = "out.{}".format(extension) + filename = f"out.{extension}" populate(item, options) with open(filename, "wb") as f: diff --git a/samples/extracts.py b/samples/extracts.py index 9bd87a47..d21bfdd0 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -47,7 +47,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 042af32e..d967659a 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -71,7 +71,7 @@ def main(): group_name = filtered_groups.pop().name print(group_name) else: - error = "No project named '{}' found".format(filter_group_name) + error = f"No project named '{filter_group_name}' found" print(error) # Or, try the above with the django style filtering diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index 7aa62a5c..6c3a85dc 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 454b225d..5f8cfa23 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 d6289605..8635947a 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 21de9783..a2c4301d 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 cb3d9e1d..cdfaf27a 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 8d72fb62..11e66469 100644 --- a/samples/list.py +++ b/samples/list.py @@ -59,7 +59,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 6a3e9e8b..847d3558 100644 --- a/samples/login.py +++ b/samples/login.py @@ -59,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("\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 +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("\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.") diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index 47af1f2f..e82c75cf 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 a7ae6dc8..a68eed4b 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 5ac76867..85f63fb3 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -111,14 +111,14 @@ 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( + "{}Datasource published. Datasource ID: {}".format( new_datasource.id, tableauserverclient.datetime_helpers.timestamp() ) ) diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 8a9f4527..d31978c0 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 4e509cd9..3309acd9 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 03daedf1..c9500089 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/update_workbook_data_acceleration.py b/samples/update_workbook_data_acceleration.py index 75f12262..57a1363e 100644 --- a/samples/update_workbook_data_acceleration.py +++ b/samples/update_workbook_data_acceleration.py @@ -43,7 +43,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/samples/update_workbook_data_freshness_policy.py b/samples/update_workbook_data_freshness_policy.py index 9e4d63dc..c23e3717 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/_version.py b/tableauserverclient/_version.py index d4737409..5d1dca9d 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("unable to find command, tried {}".format(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("Tried directories {} but none started with prefix {}".format(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, "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/models/column_item.py b/tableauserverclient/models/column_item.py index df936e31..3a7416e2 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 d61bbb75..bb2cbbba 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 62ff530c..937e4348 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 246a19e7..de917bf4 100644 --- a/tableauserverclient/models/custom_view_item.py +++ b/tableauserverclient/models/custom_view_item.py @@ -2,7 +2,7 @@ from defusedxml import ElementTree from defusedxml.ElementTree import fromstring, tostring -from typing import Callable, List, Optional +from typing import Callable, Optional from .exceptions import UnpopulatedPropertyError from .user_item import UserItem @@ -11,7 +11,7 @@ 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 @@ -35,7 +35,7 @@ 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 @@ -104,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) """ @@ -121,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 7424e6b9..3a8883be 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 65be233e..7285ee60 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 f567c501..6e0cb900 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 dfc58e1b..4d460446 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 e4e71c4a..1b082c15 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 ada04148..fbda9d9f 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 caff755e..f157283c 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -9,20 +9,18 @@ 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: str, namespace: dict) -> FavoriteType: favorites: FavoriteType = { "datasources": [], "flows": [], diff --git a/tableauserverclient/models/fileupload_item.py b/tableauserverclient/models/fileupload_item.py index e9bdd25b..aea4dfe1 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 edce2ec9..9bcad5e8 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 12281f4f..f2f1d561 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 6c8f7eb0..6871f8b1 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 ffb57adf..aa653a79 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 444674e1..d7cf891c 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 155ce668..cc7cd581 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 ae9b6042..14a0e497 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 d8ba8e82..432fd861 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 8cebd1c8..f30519be 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 26f4ee7e..3e4fec22 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 @@ -47,12 +47,12 @@ def __repr__(self): 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 +66,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 +86,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 +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) @@ -116,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("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 +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 @@ -146,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("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 9fb38288..d875abbd 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,14 @@ from tableauserverclient.models.property_decorators import property_is_enum, property_not_empty -class ProjectItem(object): +class ProjectItem: 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" ) @@ -158,7 +158,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 +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 ce31b142..5048b349 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 710548fc..4c1fff56 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 a0e6a1bd..1b4cc624 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 e416643b..e3904205 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 57fc51af..5c3f6acc 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -6,7 +6,7 @@ from tableauserverclient.helpers.logging import logger -class ServerInfoItem(object): +class ServerInfoItem: def __init__(self, product_version, build_number, rest_api_version): self._product_version = product_version self._build_number = build_number @@ -40,11 +40,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(f"Unexpected response for ServerInfo: {resp}") logger.info(error) return cls("Unknown", "Unknown", "Unknown") except Exception as error: - logger.info("Unexpected response for ServerInfo: {}".format(resp)) + logger.info(f"Unexpected response for ServerInfo: {resp}") logger.info(error) return cls("Unknown", "Unknown", "Unknown") diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index b651e577..2d9f014a 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -14,13 +14,13 @@ 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: _user_quota: Optional[int] = None _tier_creator_capacity: Optional[int] = None _tier_explorer_capacity: Optional[int] = None @@ -873,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 e96fcc44..61c75e2d 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 f9df8a8f..0afdd4df 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 10cf5872..c1e9d62b 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" @@ -42,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): @@ -69,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, @@ -95,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 bac07207..ea2a5e4f 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -32,4 +32,4 @@ def plural_type(content_type: Resource) -> 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 afa0a076..cde755f0 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 01cfcfb1..fa6f782b 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 fe659575..fb29492e 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,7 +18,7 @@ from tableauserverclient.server import Pager -class UserItem(object): +class UserItem: tag_name: str = "user" class Roles: @@ -57,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 @@ -69,7 +69,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 +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) @@ -210,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) @@ -283,7 +283,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 +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: @@ -337,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 @@ -345,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("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 +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]] = [ [], [], [], @@ -373,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("> 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 a26e364a..dc5f37a4 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 76a3b5de..e9e22be1 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 e4d5e4aa..98d821fb 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 58fd2a9a..ab5ff415 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,7 @@ from .data_freshness_policy_item import DataFreshnessPolicyItem -class WorkbookItem(object): +class WorkbookItem: 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 +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, @@ -56,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 ) @@ -64,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) @@ -152,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 @@ -191,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) @@ -203,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: @@ -316,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 d225ecff..54ac46d8 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/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 468d469a..231052f7 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: @@ -42,7 +42,7 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: Creates a context manager that will sign out of the server upon exit. """ - 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,7 +63,7 @@ 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. @@ -78,7 +78,7 @@ def sign_in_with_json_web_token(self, auth_req: "Credentials") -> contextmgr: @api(version="2.0") def sign_out(self) -> None: - url = "{0}/{1}".format(self.baseurl, "signout") + 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 +88,7 @@ def sign_out(self) -> None: @api(version="2.6") def switch_site(self, site_item: "SiteItem") -> contextmgr: - url = "{0}/{1}".format(self.baseurl, "switchSite") + 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 +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("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") + 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 57a5b010..baed9114 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -2,7 +2,7 @@ import logging import os from pathlib import Path -from typing import List, Optional, Tuple, Union +from typing import Optional, Union from tableauserverclient.config import BYTES_PER_MB, FILESIZE_LIMIT_MB from tableauserverclient.filesys_helpers import get_file_object_size @@ -33,11 +33,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 +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) @@ -68,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("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,10 +83,10 @@ 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 @@ -105,10 +105,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 +117,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: diff --git a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py index 256a6e76..57900115 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 fd02d2e4..ba3ecd74 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 2f8fece0..c0e106eb 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 7f3a4707..38ef5075 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 @@ -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,12 +260,12 @@ 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: @@ -276,12 +275,12 @@ def publish( ) ) 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 19112d71..343d8b09 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,18 +34,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"" __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)) + ) -> 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 @@ -65,29 +66,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: 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)) + ) -> 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 5296523e..90e31483 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 be0602df..bef96fde 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, ) @@ -56,7 +53,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 +79,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 +87,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 +108,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,14 +126,14 @@ 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 @@ -154,9 +151,9 @@ 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: @@ -183,9 +180,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 +310,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 +350,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 9dfd38da..17d789d0 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -12,10 +12,10 @@ def __init__(self, code, summary, detail, url=None): 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): @@ -40,7 +40,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): diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 5f298f37..8330e6d2 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 0d30797c..1ae10e72 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 c339a064..3d09ad56 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_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 QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException, FlowRunCancelledException @@ -16,16 +16,16 @@ 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]: + 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) @@ -39,8 +39,8 @@ 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] @@ -51,9 +51,9 @@ def cancel(self, flow_run_id: str) -> None: 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 +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("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 eea3f971..9e21661e 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 53d072f5..7eb5dc3b 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 8acf3169..c512b011 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 06e7cc62..c7f5ed0e 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 ae8cf263..723d3dd3 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 37413050..ede4d38e 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 38c3eebb..e5dbcbcf 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 ab1ec585..3fea1f5b 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 4433625f..10d420ff 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 565817e3..4d139fe6 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -7,7 +7,7 @@ from tableauserverclient.server import RequestFactory, RequestOptions 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 +20,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 +43,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 +54,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 +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 = "{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") diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 1894e3b8..63c03b3e 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 cfaee332..4ed243b2 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: @@ -133,13 +133,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 26aaf291..ab731c11 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -21,11 +21,11 @@ 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) + return f"{self.parent_srv.baseurl}/serverInfo" @api(version="2.4") def get(self): diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index dfec49ae..0f3d2590 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -8,7 +8,7 @@ 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 @@ -17,11 +17,11 @@ class Sites(Endpoint): @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]: logger.info("Querying all sites on site") logger.info("Requires Server Admin permissions") url = self.baseurl @@ -40,8 +40,8 @@ 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] @@ -52,8 +52,8 @@ def get_by_name(self, site_name: str) -> SiteItem: 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] @@ -68,9 +68,9 @@ 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] @@ -90,10 +90,10 @@ 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) @@ -103,13 +103,13 @@ def delete(self, site_id: str) -> None: 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") @@ -123,7 +123,7 @@ 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") @@ -131,7 +131,7 @@ def encrypt_extracts(self, site_id: str) -> None: 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) @@ -140,7 +140,7 @@ def decrypt_extracts(self, site_id: str) -> None: 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) @@ -149,7 +149,7 @@ def re_encrypt_extracts(self, site_id: str) -> None: 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 a9f2e7bf..c9abc9b0 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 36ef78c0..120d3ba9 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 a727a515..eb82c43b 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 c4b6418b..79363839 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 @@ -16,11 +16,11 @@ class Users(QuerysetEndpoint[UserItem]): @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]: logger.info("Querying all users on site") if req_options is None: @@ -39,8 +39,8 @@ def get_by_id(self, user_id: str) -> UserItem: 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() @@ -51,10 +51,10 @@ def update(self, user_item: UserItem, password: Optional[str] = None) -> UserIte 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) @@ -64,27 +64,27 @@ def remove(self, user_id: str, map_assets_to: Optional[str] = None) -> None: 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: 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 +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"): @@ -133,10 +133,10 @@ 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 @@ -161,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 = "{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 f2ccf658..3709fc41 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 f71db00c..944b7250 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 597f9c42..06643f99 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 da6eda3d..5e4442b6 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -25,15 +25,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 +57,18 @@ 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]: logger.info("Querying all workbooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -86,15 +82,15 @@ def get_by_id(self, workbook_id: str) -> WorkbookItem: 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: 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 +103,10 @@ def create_extract( workbook_item: WorkbookItem, encrypt: bool = False, includeAll: bool = True, - datasources: Optional[List["DatasourceItem"]] = None, + datasources: Optional[list["DatasourceItem"]] = None, ) -> JobItem: 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) @@ -121,7 +117,7 @@ def create_extract( @api(version="3.3") def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True, datasources=None) -> 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] @@ -133,9 +129,9 @@ def delete(self, workbook_id: str) -> 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") @@ -152,27 +148,25 @@ 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) + 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 @@ -199,14 +193,14 @@ def populate_views(self, workbook_item: WorkbookItem, usage: bool = False) -> No 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) @@ -228,12 +222,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 @@ -249,10 +243,10 @@ 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 @@ -267,10 +261,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 @@ -286,10 +280,10 @@ 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 @@ -322,7 +316,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:] @@ -346,12 +340,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 +356,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 +397,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,11 +409,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(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 @@ -433,12 +427,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 @@ -456,9 +450,9 @@ def download_revision( 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,9 +474,7 @@ 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") @@ -492,17 +484,17 @@ def delete_revision(self, workbook_id: str, revision_number: str) -> None: 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 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]: return super().add_tags(item, tags) @api(version="1.0") diff --git a/tableauserverclient/server/filter.py b/tableauserverclient/server/filter.py index b936ceb9..fd90e281 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 ca9d8387..e6d261b6 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 bbca612e..e72b29ab 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,6 +1,7 @@ from collections.abc import 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 +from collections.abc import Iterable, Iterator from tableauserverclient.config import config from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.filter import Filter @@ -37,7 +38,7 @@ class QuerySet(Iterable[T], Sized): 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]: @@ -56,12 +57,10 @@ def __iter__(self: Self) -> Iterator[T]: 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 @@ -160,22 +159,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 96fa1468..f7bd139d 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 ddb45834..fedf3ab4 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -9,12 +9,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,7 +22,7 @@ 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 @@ -183,7 +183,7 @@ def _append_view_filters(self, params) -> None: class CSVRequestOptions(_FilterOptionsBase): def __init__(self, maxage=-1): - super(CSVRequestOptions, self).__init__() + super().__init__() self.max_age = maxage @property @@ -233,7 +233,7 @@ class Resolution: High = "high" def __init__(self, imageresolution=None, maxage=-1): - super(ImageRequestOptions, self).__init__() + super().__init__() self.image_resolution = imageresolution self.max_age = maxage @@ -278,7 +278,7 @@ 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__() self.page_type = page_type self.orientation = orientation self.max_age = maxage diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index e563a713..dab5911d 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -58,7 +58,7 @@ default_server_version = "2.4" # first version that dropped the legacy auth endpoint -class Server(object): +class Server: class PublishMode: Append = "Append" Overwrite = "Overwrite" @@ -130,7 +130,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 +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("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 +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("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 +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 = "{} 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 2d6bc030..839a8c8d 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/test_dataalert.py b/test/test_dataalert.py index d9e00a9d..6f6f1683 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 624eb93e..45d9ba9c 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 8635af97..ff1ef0f7 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 6f0be3b3..87332d70 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 4c8fb0f9..0f3234d5 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 50a5ef48..9567bc3a 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 864c0d3c..e1ddd554 100644 --- a/test/test_flowruns.py +++ b/test/test_flowruns.py @@ -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,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("{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) diff --git a/test/test_flowtask.py b/test/test_flowtask.py index 034066e6..2d9f7c7b 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 fc9c75a6..41b5992b 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 d8639708..20b23876 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_project.py b/test/test_project.py index e05785f8..430db84b 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 772704f6..62e30159 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 e48f8510..9ca9779a 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): diff --git a/test/test_schedule.py b/test/test_schedule.py index 0377295d..1d329f86 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) @@ -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_site_model.py b/test/test_site_model.py index f62eb66f..60ad9c5e 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 0184af41..23dffebf 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 53da7c16..2d724b87 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 1f5eba57..a4662484 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 d0997b9f..a8a2c51c 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 1c667a4c..a89a6d23 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 6f94f0c1..766831b0 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 950118dc..1a6b3192 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 86c240e1..cce899f5 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 2a7fb2bf5e63411e1aac1b4cea0a93c6171740eb Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 20 Sep 2024 01:36:53 -0500 Subject: [PATCH 03/28] chore(typing): include samples in type checks (#1455) * chore(typing): include samples in type checks Including the sample scripts in type checking will allow more thorough testing to validate the samples work as expected, as well as more testing around how a library consumer may use the library. --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Co-authored-by: Jac --- pyproject.toml | 2 +- samples/explore_favorites.py | 6 +++--- samples/list.py | 3 +++ tableauserverclient/models/favorites_item.py | 5 +++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cc3bf8fa..c3cb67ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ disable_error_code = [ # 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 diff --git a/samples/explore_favorites.py b/samples/explore_favorites.py index 364e078c..f199522e 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(): @@ -46,8 +46,8 @@ def main(): # 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 diff --git a/samples/list.py b/samples/list.py index 11e66469..2675a295 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)) diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index f157283c..4fea280f 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -1,8 +1,9 @@ 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 @@ -20,7 +21,7 @@ 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": [], From 6ec632e328a744be4be733b1a0c697f74bf3a3c1 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 20 Sep 2024 15:03:13 -0500 Subject: [PATCH 04/28] fix: queryset support for flowruns (#1460) * fix: queryset support for flowruns FlowRun's get endpoint does not return a PaginationItem. This provides a tweak to QuerySet to provide a workaround so all items matching whatever filters are supplied. It also corrects the return types of flowruns.get and fixes the XML test asset to reflect what is really returned by the server. * fix: set unknown size to sys.maxsize Users may length check a QuerySet as part of a normal workflow. A len of 0 would be misleading, indicating to the user that there are no matches for the endpoint and/or filters they supplied. __len__ must return a non-negative int. Sentinel values such as -1 or None do not work. This only leaves maxsize as the possible flag. * fix: docstring on QuerySet * refactor(test): extract error factory to _utils * chore(typing): flowruns.cancel can also accept a FlowRunItem * style: black --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .../server/endpoint/flow_runs_endpoint.py | 14 ++-- tableauserverclient/server/query.py | 64 ++++++++++++++++--- test/_utils.py | 14 ++++ test/assets/flow_runs_get.xml | 3 +- test/test_flowruns.py | 17 ++++- 5 files changed, 92 insertions(+), 20 deletions(-) diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 3d09ad56..2c3bb84b 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 +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 @@ -25,13 +25,15 @@ def baseurl(self) -> str: # 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") @@ -46,7 +48,7 @@ def get_by_id(self, flow_run_id: str) -> FlowRunItem: # 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) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index e72b29ab..feebc1a7 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,9 +1,10 @@ -from collections.abc import Sized +from collections.abc import Iterable, Iterator, Sized from itertools import count from typing import Optional, Protocol, TYPE_CHECKING, TypeVar, overload -from collections.abc import Iterable, Iterator +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 @@ -35,6 +36,32 @@ 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) @@ -50,10 +77,20 @@ def __iter__(self: Self) -> Iterator[T]: for page in count(1): self.request_options.pagenumber = page self._result_cache = [] - self._fetch_all() + 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 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 @@ -114,10 +151,15 @@ 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) + 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 self.total_available or sys.maxsize @property def total_available(self: Self) -> int: @@ -127,12 +169,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: diff --git a/test/_utils.py b/test/_utils.py index 8527aaf8..b4ee93bc 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 bdce4cdf..489e8ac6 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/test_flowruns.py b/test/test_flowruns.py index e1ddd554..8af2540d 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)) @@ -98,3 +98,14 @@ def test_wait_for_job_timeout(self) -> None: 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 From 9a310040c8d9da5f762cbe0bf62653619fff521b Mon Sep 17 00:00:00 2001 From: Jac Date: Sat, 28 Sep 2024 11:32:20 -0700 Subject: [PATCH 05/28] #1464 - docs update for filtering on boolean values (#1471) Add docs mention of boolean values for filtering --- tableauserverclient/server/request_options.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index fedf3ab4..a3ad0c49 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -164,13 +164,14 @@ def get_query_params(self): raise NotImplementedError() 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 From d480b7570c6d0db06ef7ac4cc8ab352c7c448807 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 30 Sep 2024 00:27:59 -0500 Subject: [PATCH 06/28] chore(versions): update remaining f-strings (#1477) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/_version.py | 4 ++-- tableauserverclient/server/endpoint/endpoint.py | 2 +- test/test_schedule.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/_version.py b/tableauserverclient/_version.py index 5d1dca9d..79dbed1d 100644 --- a/tableauserverclient/_version.py +++ b/tableauserverclient/_version.py @@ -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 {}".format(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 {} but none started with prefix {}".format(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") diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index bef96fde..29912de6 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -140,7 +140,7 @@ def _make_request( 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) diff --git a/test/test_schedule.py b/test/test_schedule.py index 1d329f86..b072522a 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -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) From e1b828120bd7164b3f20be43123335a977293784 Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 30 Sep 2024 14:46:00 -0700 Subject: [PATCH 07/28] #1475 Add 'description' to datasource sample code (#1475) Update explore_datasource.py --- samples/explore_datasource.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index 877c5f08..c9f35d5b 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -51,6 +51,7 @@ 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 ) @@ -72,6 +73,10 @@ def main(): 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") From b49eac5766e61260c6f8ba56d2671b8f44f5b1b1 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 10 Oct 2024 14:09:07 -0500 Subject: [PATCH 08/28] feat(exceptions): separate failed signin error (#1478) * feat(exceptions): separate failed signin error Closes #1472 This makes sign in failures their own class of exceptions, while still inheriting from NotSignedInException to not break backwards compatability for any existing client code. This should allow users to get out more specific exceptions more easily on what failed with their authentication request. * fix(error): raise exception when ServerInfo.get fails If ServerInfoItem.from_response gets invalid XML, raise the error immediately instead of suppressing the error and setting an invalid version number * fix(test): add missing test asset --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/__init__.py | 2 + .../models/server_info_item.py | 8 +-- tableauserverclient/server/__init__.py | 3 +- .../server/endpoint/endpoint.py | 3 +- .../server/endpoint/exceptions.py | 24 ++++++-- test/assets/server_info_wrong_site.html | 56 +++++++++++++++++++ test/test_auth.py | 6 +- test/test_server_info.py | 10 ++++ 8 files changed, 98 insertions(+), 14 deletions(-) create mode 100644 test/assets/server_info_wrong_site.html diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index bab2cf05..1299c33b 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -56,6 +56,7 @@ PDFRequestOptions, RequestOptions, MissingRequiredFieldError, + FailedSignInError, NotSignedInError, ServerResponseError, Filter, @@ -79,6 +80,7 @@ "DatabaseItem", "DataFreshnessPolicyItem", "DatasourceItem", + "FailedSignInError", "FavoriteItem", "FlowItem", "FlowRunItem", diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index 5c3f6acc..4b299b29 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -40,13 +40,11 @@ def from_response(cls, resp, ns): try: parsed_response = fromstring(resp) except xml.etree.ElementTree.ParseError as error: - logger.info(f"Unexpected response for ServerInfo: {resp}") - logger.info(error) + logger.exception(f"Unexpected response for ServerInfo: {resp}") return cls("Unknown", "Unknown", "Unknown") except Exception as error: - logger.info(f"Unexpected response for ServerInfo: {resp}") - logger.info(error) - return cls("Unknown", "Unknown", "Unknown") + logger.exception(f"Unexpected response for ServerInfo: {resp}") + raise error product_version_tag = parsed_response.find(".//t:productVersion", namespaces=ns) rest_api_version_tag = parsed_response.find(".//t:restApiVersion", namespaces=ns) diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index f5cd1d23..87cc9460 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -11,7 +11,7 @@ from tableauserverclient.server.sort import Sort from tableauserverclient.server.server import Server from tableauserverclient.server.pager import Pager -from tableauserverclient.server.endpoint.exceptions import NotSignedInError +from tableauserverclient.server.endpoint.exceptions import FailedSignInError, NotSignedInError from tableauserverclient.server.endpoint import ( Auth, @@ -57,6 +57,7 @@ "Sort", "Server", "Pager", + "FailedSignInError", "NotSignedInError", "Auth", "CustomViews", diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 29912de6..9e116070 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -19,6 +19,7 @@ from tableauserverclient.server.request_options import RequestOptions from tableauserverclient.server.endpoint.exceptions import ( + FailedSignInError, ServerResponseError, InternalServerError, NonXMLResponseError, @@ -160,7 +161,7 @@ def _check_status(self, server_response: "Response", url: Optional[str] = None): try: if server_response.status_code == 401: # TODO: catch this in server.py and attempt to sign in again, in case it's a session expiry - raise NotSignedInError(server_response.content, url) + raise FailedSignInError.from_response(server_response.content, self.parent_srv.namespace, url) raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace, url) except ParseError: diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 17d789d0..77332da3 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -1,13 +1,20 @@ from defusedxml.ElementTree import fromstring -from typing import Optional +from typing import Mapping, Optional, TypeVar + + +def split_pascal_case(s: str) -> str: + return "".join([f" {c}" if c.isupper() else c for c in s]).strip() class TableauError(Exception): pass -class ServerResponseError(TableauError): - def __init__(self, code, summary, detail, url=None): +T = TypeVar("T") + + +class XMLError(TableauError): + def __init__(self, code: str, summary: str, detail: str, url: Optional[str] = None) -> None: self.code = code self.summary = summary self.detail = detail @@ -18,7 +25,7 @@ def __str__(self): return f"\n\n\t{self.code}: {self.summary}\n\t\t{self.detail}" @classmethod - def from_response(cls, resp, ns, url=None): + def from_response(cls, resp, ns, url): # Check elements exist before .text parsed_response = fromstring(resp) try: @@ -33,6 +40,10 @@ def from_response(cls, resp, ns, url=None): return error_response +class ServerResponseError(XMLError): + pass + + class InternalServerError(TableauError): def __init__(self, server_response, request_url: Optional[str] = None): self.code = server_response.status_code @@ -51,6 +62,11 @@ class NotSignedInError(TableauError): pass +class FailedSignInError(XMLError, NotSignedInError): + def __str__(self): + return f"{split_pascal_case(self.__class__.__name__)}: {super().__str__()}" + + class ItemTypeNotAllowed(TableauError): pass diff --git a/test/assets/server_info_wrong_site.html b/test/assets/server_info_wrong_site.html new file mode 100644 index 00000000..e92daeb2 --- /dev/null +++ b/test/assets/server_info_wrong_site.html @@ -0,0 +1,56 @@ + + + + + + Example website + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ABCDE
12345
23456
34567
45678
56789
+ + + \ No newline at end of file diff --git a/test/test_auth.py b/test/test_auth.py index eaf13481..48100ad8 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -63,7 +63,7 @@ def test_sign_in_error(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("testuser", "wrongpassword") - self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_invalid_token(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -71,7 +71,7 @@ def test_sign_in_invalid_token(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.PersonalAccessTokenAuth(token_name="mytoken", personal_access_token="invalid") - self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_without_auth(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -79,7 +79,7 @@ def test_sign_in_without_auth(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("", "") - self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) def test_sign_out(self): with open(SIGN_IN_XML, "rb") as f: diff --git a/test/test_server_info.py b/test/test_server_info.py index 1cf190ec..fa1472c9 100644 --- a/test/test_server_info.py +++ b/test/test_server_info.py @@ -4,6 +4,7 @@ import requests_mock import tableauserverclient as TSC +from tableauserverclient.server.endpoint.exceptions import NonXMLResponseError TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -11,6 +12,7 @@ SERVER_INFO_25_XML = os.path.join(TEST_ASSET_DIR, "server_info_25.xml") SERVER_INFO_404 = os.path.join(TEST_ASSET_DIR, "server_info_404.xml") SERVER_INFO_AUTH_INFO_XML = os.path.join(TEST_ASSET_DIR, "server_info_auth_info.xml") +SERVER_INFO_WRONG_SITE = os.path.join(TEST_ASSET_DIR, "server_info_wrong_site.html") class ServerInfoTests(unittest.TestCase): @@ -63,3 +65,11 @@ def test_server_use_server_version_flag(self): m.get("http://test/api/2.4/serverInfo", text=si_response_xml) server = TSC.Server("http://test", use_server_version=True) self.assertEqual(server.version, "2.5") + + def test_server_wrong_site(self): + with open(SERVER_INFO_WRONG_SITE, "rb") as f: + response = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.server.server_info.baseurl, text=response, status_code=404) + with self.assertRaises(NonXMLResponseError): + self.server.server_info.get() From 9495fe8109aa30bab27dc262a10666ce8f55eb5c Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 10 Oct 2024 14:36:33 -0500 Subject: [PATCH 09/28] docs: add docstrings to auth objects and endpoints (#1484) * docs: add docstrings to auth objects and endpoints * docs: add parameters and examples to methods --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/tableau_auth.py | 110 ++++++++++++++++++ .../server/endpoint/auth_endpoint.py | 57 +++++++++ 2 files changed, 167 insertions(+) diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index c1e9d62b..7d798143 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -32,6 +32,43 @@ def deprecate_site_attribute(): # The traditional auth type: username/password class TableauAuth(Credentials): + """ + The TableauAuth class defines the information you can set in a sign-in + request. The class members correspond to the attributes of a server request + or response payload. To use this class, create a new instance, supplying + user name, password, and site information if necessary, and pass the + request object to the Auth.sign_in method. + + Parameters + ---------- + username : str + The user name for the sign-in request. + + password : str + The password for the sign-in request. + + site_id : str, optional + This corresponds to the contentUrl attribute in the Tableau REST API. + The site_id is the portion of the URL that follows the /site/ in the + URL. For example, "MarketingTeam" is the site_id in the following URL + MyServer/#/site/MarketingTeam/projects. To specify the default site on + Tableau Server, you can use an empty string '' (single quotes, no + space). For Tableau Cloud, you must provide a value for the site_id. + + user_id_to_impersonate : str, optional + Specifies the id (not the name) of the user to sign in as. This is not + available for Tableau Online. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD', site_id='CONTENTURL') + >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) + >>> server.auth.sign_in(tableau_auth) + + """ + def __init__( self, username: str, password: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None ) -> None: @@ -55,6 +92,43 @@ def __repr__(self): # A Tableau-generated Personal Access Token class PersonalAccessTokenAuth(Credentials): + """ + The PersonalAccessTokenAuth class defines the information you can set in a sign-in + request. The class members correspond to the attributes of a server request + or response payload. To use this class, create a new instance, supplying + token name, token secret, and site information if necessary, and pass the + request object to the Auth.sign_in method. + + Parameters + ---------- + token_name : str + The name of the personal access token. + + personal_access_token : str + The personal access token secret for the sign in request. + + site_id : str, optional + This corresponds to the contentUrl attribute in the Tableau REST API. + The site_id is the portion of the URL that follows the /site/ in the + URL. For example, "MarketingTeam" is the site_id in the following URL + MyServer/#/site/MarketingTeam/projects. To specify the default site on + Tableau Server, you can use an empty string '' (single quotes, no + space). For Tableau Cloud, you must provide a value for the site_id. + + user_id_to_impersonate : str, optional + Specifies the id (not the name) of the user to sign in as. This is not + available for Tableau Online. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> tableau_auth = TSC.PersonalAccessTokenAuth("token_name", "token_secret", site_id='CONTENTURL') + >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) + >>> server.auth.sign_in(tableau_auth) + + """ + def __init__( self, token_name: str, @@ -88,6 +162,42 @@ def __repr__(self): # A standard JWT generated specifically for Tableau class JWTAuth(Credentials): + """ + The JWTAuth class defines the information you can set in a sign-in + request. The class members correspond to the attributes of a server request + or response payload. To use this class, create a new instance, supplying + an encoded JSON Web Token, and site information if necessary, and pass the + request object to the Auth.sign_in method. + + Parameters + ---------- + token : str + The encoded JSON Web Token. + + site_id : str, optional + This corresponds to the contentUrl attribute in the Tableau REST API. + The site_id is the portion of the URL that follows the /site/ in the + URL. For example, "MarketingTeam" is the site_id in the following URL + MyServer/#/site/MarketingTeam/projects. To specify the default site on + Tableau Server, you can use an empty string '' (single quotes, no + space). For Tableau Cloud, you must provide a value for the site_id. + + user_id_to_impersonate : str, optional + Specifies the id (not the name) of the user to sign in as. This is not + available for Tableau Online. + + Examples + -------- + >>> import jwt + >>> import tableauserverclient as TSC + + >>> jwt_token = jwt.encode(...) + >>> tableau_auth = TSC.JWTAuth(token, site_id='CONTENTURL') + >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) + >>> server.auth.sign_in(tableau_auth) + + """ + def __init__(self, jwt: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None) -> None: if jwt is None: raise TabError("Must provide a JWT token when using JWT authentication") diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 231052f7..4211bb7e 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -41,6 +41,30 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: optionally a user_id to impersonate. Creates a context manager that will sign out of the server upon exit. + + Parameters + ---------- + auth_req : Credentials + The credentials object to use for signing in. Can be a TableauAuth, + PersonalAccessTokenAuth, or JWTAuth object. + + Returns + ------- + contextmgr + A context manager that will sign out of the server upon exit. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create an auth object + >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') + + >>> # create an instance for your server + >>> server = TSC.Server('https://SERVER_URL') + + >>> # call the sign-in method with the auth object + >>> server.auth.sign_in(tableau_auth) """ url = f"{self.baseurl}/signin" signin_req = RequestFactory.Auth.signin_req(auth_req) @@ -70,14 +94,17 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: # The distinct methods are mostly useful for explicitly showing api version support for each auth type @api(version="3.6") def sign_in_with_personal_access_token(self, auth_req: "Credentials") -> contextmgr: + """Passthrough to sign_in method""" return self.sign_in(auth_req) @api(version="3.17") def sign_in_with_json_web_token(self, auth_req: "Credentials") -> contextmgr: + """Passthrough to sign_in method""" return self.sign_in(auth_req) @api(version="2.0") def sign_out(self) -> None: + """Sign out of current session.""" url = f"{self.baseurl}/signout" # If there are no auth tokens you're already signed out. No-op if not self.parent_srv.is_signed_in(): @@ -88,6 +115,33 @@ def sign_out(self) -> None: @api(version="2.6") def switch_site(self, site_item: "SiteItem") -> contextmgr: + """ + Switch to a different site on the server. This will sign out of the + current site and sign in to the new site. If used as a context manager, + will sign out of the new site upon exit. + + Parameters + ---------- + site_item : SiteItem + The site to switch to. + + Returns + ------- + contextmgr + A context manager that will sign out of the new site upon exit. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # Find the site you want to switch to + >>> new_site = server.sites.get_by_id("9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d") + >>> # switch to the new site + >>> with server.auth.switch_site(new_site): + >>> # do something on the new site + >>> pass + + """ url = f"{self.baseurl}/switchSite" switch_req = RequestFactory.Auth.switch_req(site_item.content_url) try: @@ -109,6 +163,9 @@ def switch_site(self, site_item: "SiteItem") -> contextmgr: @api(version="3.10") def revoke_all_server_admin_tokens(self) -> None: + """ + Revokes all personal access tokens for all server admins on the server. + """ url = f"{self.baseurl}/revokeAllServerAdminTokens" self.post_request(url, "") logger.info("Revoked all tokens for all server admins") From 0af55124903e3902e37e5cb8126cdcf5a39f3aa2 Mon Sep 17 00:00:00 2001 From: Henning Merklinger Date: Thu, 10 Oct 2024 22:23:05 +0200 Subject: [PATCH 10/28] Set FILESIZE_LIMIT_MB via environment variables (#1466) * add TSC_FILESIZE_LIMIT_MB environment variable * add hard limit for filesize limit at 64MB * fix formatting --------- Co-authored-by: Jac --- tableauserverclient/config.py | 8 +++++--- .../server/endpoint/custom_views_endpoint.py | 4 ++-- .../server/endpoint/datasources_endpoint.py | 6 +++--- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py index 63872398..a7511275 100644 --- a/tableauserverclient/config.py +++ b/tableauserverclient/config.py @@ -6,11 +6,13 @@ DELAY_SLEEP_SECONDS = 0.1 -# The maximum size of a file that can be published in a single request is 64MB -FILESIZE_LIMIT_MB = 64 - class Config: + # The maximum size of a file that can be published in a single request is 64MB + @property + def FILESIZE_LIMIT_MB(self): + return min(int(os.getenv("TSC_FILESIZE_LIMIT_MB", 64)), 64) + # For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks @property def CHUNK_SIZE_MB(self): diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index baed9114..63899ba0 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Optional, Union -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 @@ -144,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 >= FILESIZE_LIMIT_MB * BYTES_PER_MB: + if size >= config.FILESIZE_LIMIT_MB * BYTES_PER_MB: upload_session_id = self.parent_srv.fileuploads.upload(file) url = f"{url}?uploadSessionId={upload_session_id}" xml_request, content_type = RequestFactory.CustomView.publish_req_chunked(view_item) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 38ef5075..6bd809c2 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -23,7 +23,7 @@ from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin -from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, config +from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, BYTES_PER_MB, config from tableauserverclient.filesys_helpers import ( make_download_path, get_file_type, @@ -268,10 +268,10 @@ def publish( url += "&{}=true".format("asJob") # Determine if chunking is required (64MB is the limit for single upload method) - if file_size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: + if file_size >= config.FILESIZE_LIMIT_MB * BYTES_PER_MB: logger.info( "Publishing {} to server with chunking method (datasource over {}MB, chunk size {}MB)".format( - filename, FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB + filename, config.FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB ) ) upload_session_id = self.parent_srv.fileuploads.upload(file) From c6dabddd993339b8a0d5edbc820234636f62914b Mon Sep 17 00:00:00 2001 From: AlbertWangXu Date: Thu, 10 Oct 2024 18:12:55 -0400 Subject: [PATCH 11/28] added PulseMetricDefine cap (#1490) Update permissions_item.py added PulseMetricDefine cap Co-authored-by: Jac --- tableauserverclient/models/permissions_item.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 3e4fec22..186cebed 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -41,6 +41,7 @@ class Capability: RunExplainData = "RunExplainData" CreateRefreshMetrics = "CreateRefreshMetrics" SaveAs = "SaveAs" + PulseMetricDefine = "PulseMetricDefine" def __repr__(self): return "" From 0efd7357d494141c17dda508252e884b8f772f5f Mon Sep 17 00:00:00 2001 From: TrimPeachu <77048868+TrimPeachu@users.noreply.github.com> Date: Fri, 11 Oct 2024 00:14:17 +0200 Subject: [PATCH 12/28] Adding project permissions handling for databases, tables and virtual connections (#1482) --- tableauserverclient/models/project_item.py | 44 +++++++++++++------ .../server/endpoint/projects_endpoint.py | 36 +++++++++++++++ 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index d875abbd..48f27c60 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -9,6 +9,8 @@ class ProjectItem: + ERROR_MSG = "Project item must be populated with permissions first." + class ContentPermissions: LockedToProject: str = "LockedToProject" ManagedByOwner: str = "ManagedByOwner" @@ -43,6 +45,9 @@ def __init__( self._default_lens_permissions = None self._default_datarole_permissions = None self._default_metric_permissions = None + self._default_virtualconnection_permissions = None + self._default_database_permissions = None + self._default_table_permissions = None @property def content_permissions(self): @@ -56,52 +61,63 @@ def content_permissions(self, value: Optional[str]) -> None: @property def permissions(self): if self._permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._permissions() @property def default_datasource_permissions(self): if self._default_datasource_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_datasource_permissions() @property def default_workbook_permissions(self): if self._default_workbook_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_workbook_permissions() @property def default_flow_permissions(self): if self._default_flow_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_flow_permissions() @property def default_lens_permissions(self): if self._default_lens_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_lens_permissions() @property def default_datarole_permissions(self): if self._default_datarole_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_datarole_permissions() @property def default_metric_permissions(self): if self._default_metric_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_metric_permissions() + @property + def default_virtualconnection_permissions(self): + if self._default_virtualconnection_permissions is None: + raise UnpopulatedPropertyError(self.ERROR_MSG) + return self._default_virtualconnection_permissions() + + @property + def default_database_permissions(self): + if self._default_database_permissions is None: + raise UnpopulatedPropertyError(self.ERROR_MSG) + return self._default_database_permissions() + + @property + def default_table_permissions(self): + if self._default_table_permissions is None: + raise UnpopulatedPropertyError(self.ERROR_MSG) + return self._default_table_permissions() + @property def id(self) -> Optional[str]: return self._id diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 4d139fe6..773b942d 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -109,6 +109,18 @@ def populate_flow_default_permissions(self, item): 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): + self._default_permissions.populate_default_permissions(item, Resource.VirtualConnection) + + @api(version="3.23") + def populate_database_default_permissions(self, item): + self._default_permissions.populate_default_permissions(item, Resource.Database) + + @api(version="3.23") + def populate_table_default_permissions(self, item): + self._default_permissions.populate_default_permissions(item, Resource.Table) + @api(version="2.1") def update_workbook_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Workbook) @@ -133,6 +145,18 @@ def update_flow_default_permissions(self, item, rules): 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, rules): + return self._default_permissions.update_default_permissions(item, rules, Resource.VirtualConnection) + + @api(version="3.23") + def update_database_default_permissions(self, item, rules): + return self._default_permissions.update_default_permissions(item, rules, Resource.Database) + + @api(version="3.23") + def update_table_default_permissions(self, item, rules): + return self._default_permissions.update_default_permissions(item, rules, Resource.Table) + @api(version="2.1") def delete_workbook_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Workbook) @@ -157,6 +181,18 @@ def delete_flow_default_permissions(self, item, rule): 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, rule): + self._default_permissions.delete_default_permission(item, rule, Resource.VirtualConnection) + + @api(version="3.23") + def delete_database_default_permissions(self, item, rule): + self._default_permissions.delete_default_permission(item, rule, Resource.Database) + + @api(version="3.23") + def delete_table_default_permissions(self, item, rule): + 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 From f8728b211d8e8eb4507f6e907e482da5a60d3577 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 11 Oct 2024 15:58:32 -0500 Subject: [PATCH 13/28] docs: docstrings for Server and ServerInfo (#1494) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .../models/server_info_item.py | 22 ++++++++ .../server/endpoint/server_info_endpoint.py | 41 +++++++++++++- tableauserverclient/server/server.py | 56 +++++++++++++++++++ 3 files changed, 116 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index 4b299b29..b13f2674 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -7,6 +7,28 @@ class ServerInfoItem: + """ + The ServerInfoItem class contains the build and version information for + Tableau Server. The server information is accessed with the + server_info.get() method, which returns an instance of the ServerInfo class. + + Attributes + ---------- + product_version : str + Shows the version of the Tableau Server or Tableau Cloud + (for example, 10.2.0). + + build_number : str + Shows the specific build number (for example, 10200.17.0329.1446). + + rest_api_version : str + Shows the supported REST API version number. Note that this might be + different from the default value specified for the server, with the + Server.version attribute. To take advantage of new features, you should + query the server and set the Server.version to match the supported REST + API version number. + """ + def __init__(self, product_version, build_number, rest_api_version): self._product_version = product_version self._build_number = build_number diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index ab731c11..dc934496 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -1,4 +1,5 @@ import logging +from typing import Union from .endpoint import Endpoint, api from .exceptions import ServerResponseError @@ -24,12 +25,46 @@ def __repr__(self): return f"" @property - def baseurl(self): + def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/serverInfo" @api(version="2.4") - def get(self): - """Retrieve the server info for the server. This is an unauthenticated call""" + def get(self) -> Union[ServerInfoItem, None]: + """ + Retrieve the build and version information for the server. + + This method makes an unauthenticated call, so no sign in or + authentication token is required. + + Returns + ------- + :class:`~tableauserverclient.models.ServerInfoItem` + + Raises + ------ + :class:`~tableauserverclient.exceptions.ServerInfoEndpointNotFoundError` + Raised when the server info endpoint is not found. + + :class:`~tableauserverclient.exceptions.EndpointUnavailableError` + Raised when the server info endpoint is not available. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create a instance of server + >>> server = TSC.Server('https://MY-SERVER') + + >>> # set the version number > 2.3 + >>> # the server_info.get() method works in 2.4 and later + >>> server.version = '2.5' + + >>> s_info = server.server_info.get() + >>> print("\nServer info:") + >>> print("\tProduct version: {0}".format(s_info.product_version)) + >>> print("\tREST API version: {0}".format(s_info.rest_api_version)) + >>> print("\tBuild number: {0}".format(s_info.build_number)) + """ try: server_response = self.get_unauthenticated_request(self.baseurl) except ServerResponseError as e: diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index dab5911d..4eeefcaf 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -59,7 +59,63 @@ class Server: + """ + In the Tableau REST API, the server (https://MY-SERVER/) is the base or core + of the URI that makes up the various endpoints or methods for accessing + resources on the server (views, workbooks, sites, users, data sources, etc.) + The TSC library provides a Server class that represents the server. You + create a server instance to sign in to the server and to call the various + methods for accessing resources. + + The Server class contains the attributes that represent the server on + Tableau Server. After you create an instance of the Server class, you can + sign in to the server and call methods to access all of the resources on the + server. + + Parameters + ---------- + server_address : str + Specifies the address of the Tableau Server or Tableau Cloud (for + example, https://MY-SERVER/). + + use_server_version : bool + Specifies the version of the REST API to use (for example, '2.5'). When + you use the TSC library to call methods that access Tableau Server, the + version is passed to the endpoint as part of the URI + (https://MY-SERVER/api/2.5/). Each release of Tableau Server supports + specific versions of the REST API. New versions of the REST API are + released with Tableau Server. By default, the value of version is set to + '2.3', which corresponds to Tableau Server 10.0. You can view or set + this value. You might need to set this to a different value, for + example, if you want to access features that are supported by the server + and a later version of the REST API. For more information, see REST API + Versions. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create a instance of server + >>> server = TSC.Server('https://MY-SERVER') + + >>> # sign in, etc. + + >>> # change the REST API version to match the server + >>> server.use_server_version() + + >>> # or change the REST API version to match a specific version + >>> # for example, 2.8 + >>> # server.version = '2.8' + + """ + class PublishMode: + """ + Enumerates the options that specify what happens when you publish a + workbook or data source. The options are Overwrite, Append, or + CreateNew. + """ + Append = "Append" Overwrite = "Overwrite" CreateNew = "CreateNew" From 89e1ddf9aa3c509426acd7d247fcd1842ace996c Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 11 Oct 2024 14:00:13 -0700 Subject: [PATCH 14/28] refactor request_options, add language param (#1481) * refactor request_options, add language param I have refactored the classes to separate options that can be used in querying content, and options that can be used for exporting data. "language" is only available for data exporting. --- pyproject.toml | 2 +- samples/export.py | 14 +- tableauserverclient/__init__.py | 48 +++-- tableauserverclient/server/request_options.py | 199 ++++++++---------- test/test_request_option.py | 10 + 5 files changed, 129 insertions(+), 144 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c3cb67ed..67faefbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,13 +43,13 @@ target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] check_untyped_defs = false disable_error_code = [ 'misc', - # tableauserverclient\server\endpoint\datasources_endpoint.py:48: error: Cannot assign multiple types to name "FilePath" without an explicit "Type[...]" annotation [misc] 'annotation-unchecked' # can be removed when check_untyped_defs = true ] files = ["tableauserverclient", "test", "samples"] show_error_codes = true ignore_missing_imports = true # defusedxml library has no types no_implicit_reexport = true +implicit_optional = true [tool.pytest.ini_options] testpaths = ["test"] diff --git a/samples/export.py b/samples/export.py index 815ec8b5..e3371046 100644 --- a/samples/export.py +++ b/samples/export.py @@ -37,7 +37,9 @@ 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("--file", "-f", help="filename to store the exported data") @@ -74,16 +76,18 @@ def main(): populate = getattr(server.workbooks, populate_func_name) option_factory = getattr(TSC, option_factory_name) + options: TSC.PDFRequestOptions = option_factory() if args.filter: - options = option_factory().vf(*args.filter.split(":")) - else: - options = None + options = options.vf(*args.filter.split(":")) + + if args.language: + options.language = args.language if args.file: filename = args.file else: - filename = f"out.{extension}" + filename = f"out-{options.language}.{extension}" populate(item, options) with open(filename, "wb") as f: diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 1299c33b..e0a7abb6 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -32,11 +32,13 @@ PermissionsRule, PersonalAccessTokenAuth, ProjectItem, + Resource, RevisionItem, ScheduleItem, SiteItem, ServerInfoItem, SubscriptionItem, + TableauItem, TableItem, TableauAuth, Target, @@ -66,66 +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/server/request_options.py b/tableauserverclient/server/request_options.py index a3ad0c49..0d47abfc 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,4 +1,5 @@ import sys +from typing import Optional from typing_extensions import Self @@ -26,11 +27,48 @@ def apply_query_params(self, url): except NotImplementedError: raise - def get_query_params(self): - raise NotImplementedError() + +# If it wasn't a breaking change, I'd rename it to QueryOptions +""" +This class manages options can be used when querying content on the server +""" class RequestOptions(RequestOptionsBase): + def __init__(self, pagenumber=1, pagesize=None): + self.pagenumber = pagenumber + self.pagesize = pagesize or config.PAGE_SIZE + self.sort = set() + self.filter = set() + # This is private until we expand all of our parsers to handle the extra fields + self._all_fields = False + + def get_query_params(self) -> dict: + params = {} + if self.sort and len(self.sort) > 0: + sort_options = (str(sort_item) for sort_item in self.sort) + ordered_sort_options = sorted(sort_options) + params["sort"] = ",".join(ordered_sort_options) + if len(self.filter) > 0: + filter_options = (str(filter_item) for filter_item in self.filter) + ordered_filter_options = sorted(filter_options) + params["filter"] = ",".join(ordered_filter_options) + if self._all_fields: + params["fields"] = "_all_" + if self.pagenumber: + params["pageNumber"] = self.pagenumber + if self.pagesize: + params["pageSize"] = self.pagesize + return params + + def page_size(self, page_size): + self.pagesize = page_size + return self + + def page_number(self, page_number): + self.pagenumber = page_number + return self + class Operator: Equals = "eq" GreaterThan = "gt" @@ -41,6 +79,7 @@ class Operator: Has = "has" CaseInsensitiveEquals = "cieq" + # These are fields in the REST API class Field: Args = "args" AuthenticationType = "authenticationType" @@ -117,51 +156,43 @@ class Direction: Desc = "desc" Asc = "asc" - def __init__(self, pagenumber=1, pagesize=None): - self.pagenumber = pagenumber - self.pagesize = pagesize or config.PAGE_SIZE - self.sort = set() - self.filter = set() - # This is private until we expand all of our parsers to handle the extra fields - self._all_fields = False - - def page_size(self, page_size): - self.pagesize = page_size - return self +""" +These options can be used by methods that are fetching data exported from a specific content item +""" - def page_number(self, page_number): - self.pagenumber = page_number - return self - - def get_query_params(self): - params = {} - if self.pagenumber: - params["pageNumber"] = self.pagenumber - if self.pagesize: - params["pageSize"] = self.pagesize - if len(self.sort) > 0: - sort_options = (str(sort_item) for sort_item in self.sort) - ordered_sort_options = sorted(sort_options) - params["sort"] = ",".join(ordered_sort_options) - if len(self.filter) > 0: - filter_options = (str(filter_item) for filter_item in self.filter) - ordered_filter_options = sorted(filter_options) - params["filter"] = ",".join(ordered_filter_options) - if self._all_fields: - params["fields"] = "_all_" - return params +class _DataExportOptions(RequestOptionsBase): + def __init__(self, maxage: int = -1): + super().__init__() + self.view_filters: list[tuple[str, str]] = [] + self.view_parameters: list[tuple[str, str]] = [] + self.max_age: Optional[int] = maxage + """ + This setting will affect the contents of the workbook as they are exported. + Valid language values are tableau-supported languages like de, es, en + If no locale is specified, the default locale for that language will be used + """ + self.language: Optional[str] = None -class _FilterOptionsBase(RequestOptionsBase): - """Provide a basic implementation of adding view filters to the url""" + @property + def max_age(self) -> int: + return self._max_age - def __init__(self): - self.view_filters = [] - self.view_parameters = [] + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value): + self._max_age = value def get_query_params(self): - raise NotImplementedError() + params = {} + if self.max_age != -1: + params["maxAge"] = self.max_age + if self.language: + params["language"] = self.language + + self._append_view_filters(params) + return params def vf(self, name: str, value: str) -> Self: """Apply a filter based on a column within the view. @@ -182,82 +213,33 @@ def _append_view_filters(self, params) -> None: params[name] = value -class CSVRequestOptions(_FilterOptionsBase): - def __init__(self, maxage=-1): - super().__init__() - self.max_age = maxage - - @property - def max_age(self): - return self._max_age +class CSVRequestOptions(_DataExportOptions): + extension = "csv" - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value): - self._max_age = value - - def get_query_params(self): - params = {} - if self.max_age != -1: - params["maxAge"] = self.max_age - - self._append_view_filters(params) - return params - - -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 - def get_query_params(self): - params = {} - if self.max_age != -1: - params["maxAge"] = self.max_age +class ExcelRequestOptions(_DataExportOptions): + extension = "xlsx" - self._append_view_filters(params) - return params +class ImageRequestOptions(_DataExportOptions): + 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): - super().__init__() + super().__init__(maxage=maxage) 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(_DataExportOptions): class PageType: A3 = "a3" A4 = "a4" @@ -279,22 +261,12 @@ class Orientation: Landscape = "landscape" def __init__(self, page_type=None, orientation=None, maxage=-1, viz_height=None, viz_width=None): - super().__init__() + super().__init__(maxage=maxage) 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 @@ -313,17 +285,14 @@ def viz_width(self): 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") @@ -334,6 +303,4 @@ def get_query_params(self): if self.viz_width is not None: params["vizWidth"] = self.viz_width - self._append_view_filters(params) - return params diff --git a/test/test_request_option.py b/test/test_request_option.py index 9ca9779a..7405189a 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -358,3 +358,13 @@ def test_queryset_pagesize_filter(self) -> None: queryset = self.server.views.all().filter(page_size=page_size) assert queryset.request_options.pagesize == page_size _ = list(queryset) + + def test_language_export(self) -> None: + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = self.baseurl + "/views/456/data" + opts = TSC.PDFRequestOptions() + opts.language = "en-US" + + resp = self.server.users.get_request(url, request_object=opts) + self.assertTrue(re.search("language=en-us", resp.request.query)) From 1b64987b4eb63f5adee21c5c4eb0038d6045e15f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 11 Oct 2024 16:02:32 -0500 Subject: [PATCH 15/28] docs: docstrings for user item and endpoint (#1485) * docs: docstrings for user item and endpoint * docs: add serverresponseerror details --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/user_item.py | 30 ++ .../server/endpoint/users_endpoint.py | 347 ++++++++++++++++++ 2 files changed, 377 insertions(+) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index fb29492e..365e44c1 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -19,9 +19,34 @@ class UserItem: + """ + The UserItem class contains the members or attributes for the view + resources on Tableau Server. The UserItem class defines the information you + can request or query from Tableau Server. The class attributes correspond + to the attributes of a server request or response payload. + + + Parameters + ---------- + name: str + The name of the user. + + site_role: str + The role of the user on the site. + + auth_setting: str + Required attribute for Tableau Cloud. How the user autenticates to the + server. + """ + tag_name: str = "user" class Roles: + """ + The Roles class contains the possible roles for a user on Tableau + Server. + """ + Interactor = "Interactor" Publisher = "Publisher" ServerAdministrator = "ServerAdministrator" @@ -43,6 +68,11 @@ class Roles: SupportUser = "SupportUser" class Auth: + """ + The Auth class contains the possible authentication settings for a user + on Tableau Cloud. + """ + OpenID = "OpenID" SAML = "SAML" TableauIDWithMFA = "TableauIDWithMFA" diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 79363839..d81907ae 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -14,6 +14,14 @@ class Users(QuerysetEndpoint[UserItem]): + """ + The user resources for Tableau Server are defined in the UserItem class. + The class corresponds to the user resources you can access using the + Tableau Server REST API. The user methods are based upon the endpoints for + users in the REST API and operate on the UserItem class. Only server and + site administrators can access the user resources. + """ + @property def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/users" @@ -21,6 +29,60 @@ def baseurl(self) -> str: # Gets all users @api(version="2.0") def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserItem], PaginationItem]: + """ + Query all users on the site. Request is paginated and returns a subset of users. + By default, the request returns the first 100 users on the site. + + Parameters + ---------- + req_options : Optional[RequestOptions] + Optional request options to filter and sort the results. + + Returns + ------- + tuple[list[UserItem], PaginationItem] + Returns a tuple with a list of UserItem objects and a PaginationItem object. + + Raises + ------ + ServerResponseError + code: 400006 + summary: Invalid page number + detail: The page number is not an integer, is less than one, or is + greater than the final page number for users at the requested + page size. + + ServerResponseError + code: 400007 + summary: Invalid page size + detail: The page size parameter is not an integer, is less than one. + + ServerResponseError + code: 403014 + summary: Page size limit exceeded + detail: The specified page size is larger than the maximum page size + + ServerResponseError + code: 404000 + summary: Site not found + detail: The site ID in the URI doesn't correspond to an existing site. + + ServerResponseError + code: 405000 + summary: Invalid request method + detail: Request type was not GET. + + Examples + -------- + >>> import tableauserverclient as TSC + >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') + >>> server = TSC.Server('https://SERVERURL') + + >>> with server.auth.sign_in(tableau_auth): + >>> users_page, pagination_item = server.users.get() + >>> print("\nThere are {} user on site: ".format(pagination_item.total_available)) + >>> print([user.name for user in users_page]) + """ logger.info("Querying all users on site") if req_options is None: @@ -36,6 +98,49 @@ def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserIt # Gets 1 user by id @api(version="2.0") def get_by_id(self, user_id: str) -> UserItem: + """ + Query a single user by ID. + + Parameters + ---------- + user_id : str + The ID of the user to query. + + Returns + ------- + UserItem + The user item that was queried. + + Raises + ------ + ValueError + If the user ID is not specified. + + ServerResponseError + code: 404000 + summary: Site not found + detail: The site ID in the URI doesn't correspond to an existing site. + + ServerResponseError + code: 403133 + summary: Query user permissions forbidden + detail: The user does not have permissions to query user information + for other users + + ServerResponseError + code: 404002 + summary: User not found + detail: The user ID in the URI doesn't correspond to an existing user. + + ServerResponseError + code: 405000 + summary: Invalid request method + detail: Request type was not GET. + + Examples + -------- + >>> user1 = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + """ if not user_id: error = "User ID undefined." raise ValueError(error) @@ -47,6 +152,47 @@ def get_by_id(self, user_id: str) -> UserItem: # Update user @api(version="2.0") def update(self, user_item: UserItem, password: Optional[str] = None) -> UserItem: + """ + Modifies information about the specified user. + + If Tableau Server is configured to use local authentication, you can + update the user's name, email address, password, or site role. + + If Tableau Server is configured to use Active Directory + authentication, you can change the user's display name (full name), + email address, and site role. However, if you synchronize the user with + Active Directory, the display name and email address will be + overwritten with the information that's in Active Directory. + + For Tableau Cloud, you can update the site role for a user, but you + cannot update or change a user's password, user name (email address), + or full name. + + Parameters + ---------- + user_item : UserItem + The user item to update. + + password : Optional[str] + The new password for the user. + + Returns + ------- + UserItem + The user item that was updated. + + Raises + ------ + MissingRequiredFieldError + If the user item is missing an ID. + + Examples + -------- + >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + >>> user.fullname = 'New Full Name' + >>> updated_user = server.users.update(user) + + """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -61,6 +207,31 @@ def update(self, user_item: UserItem, password: Optional[str] = None) -> UserIte # Delete 1 user by id @api(version="2.0") def remove(self, user_id: str, map_assets_to: Optional[str] = None) -> None: + """ + Removes a user from the site. You can also specify a user to map the + assets to when you remove the user. + + Parameters + ---------- + user_id : str + The ID of the user to remove. + + map_assets_to : Optional[str] + The ID of the user to map the assets to when you remove the user. + + Returns + ------- + None + + Raises + ------ + ValueError + If the user ID is not specified. + + Examples + -------- + >>> server.users.remove('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + """ if not user_id: error = "User ID undefined." raise ValueError(error) @@ -73,6 +244,95 @@ def remove(self, user_id: str, map_assets_to: Optional[str] = None) -> None: # Add new user to site @api(version="2.0") def add(self, user_item: UserItem) -> UserItem: + """ + Adds the user to the site. + + To add a new user to the site you need to first create a new user_item + (from UserItem class). When you create a new user, you specify the name + of the user and their site role. For Tableau Cloud, you also specify + the auth_setting attribute in your request. When you add user to + Tableau Cloud, the name of the user must be the email address that is + used to sign in to Tableau Cloud. After you add a user, Tableau Cloud + sends the user an email invitation. The user can click the link in the + invitation to sign in and update their full name and password. + + Parameters + ---------- + user_item : UserItem + The user item to add to the site. + + Returns + ------- + UserItem + The user item that was added to the site with attributes from the + site populated. + + Raises + ------ + ValueError + If the user item is missing a name + + ValueError + If the user item is missing a site role + + ServerResponseError + code: 400000 + summary: Bad Request + detail: The content of the request body is missing or incomplete, or + contains malformed XML. + + ServerResponseError + code: 400003 + summary: Bad Request + detail: The user authentication setting ServerDefault is not + supported for you site. Try again using TableauIDWithMFA instead. + + ServerResponseError + code: 400013 + summary: Invalid site role + detail: The value of the siteRole attribute must be Explorer, + ExplorerCanPublish, SiteAdministratorCreator, + SiteAdministratorExplorer, Unlicensed, or Viewer. + + ServerResponseError + code: 404000 + summary: Site not found + detail: The site ID in the URI doesn't correspond to an existing site. + + ServerResponseError + code: 404002 + summary: User not found + detail: The server is configured to use Active Directory for + authentication, and the username specified in the request body + doesn't match an existing user in Active Directory. + + ServerResponseError + code: 405000 + summary: Invalid request method + detail: Request type was not POST. + + ServerResponseError + code: 409000 + summary: User conflict + detail: The specified user already exists on the site. + + ServerResponseError + code: 409005 + summary: Guest user conflict + detail: The Tableau Server API doesn't allow adding a user with the + guest role to a site. + + + Examples + -------- + >>> import tableauserverclient as TSC + >>> server = TSC.Server('https://SERVERURL') + >>> # Login to the server + + >>> new_user = TSC.UserItem(name='new_user', site_role=TSC.UserItem.Role.Unlicensed) + >>> new_user = server.users.add(new_user) + + """ url = self.baseurl logger.info(f"Add user {user_item.name}") add_req = RequestFactory.User.add_req(user_item) @@ -122,6 +382,42 @@ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[Us # Get workbooks for user @api(version="2.0") def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: + """ + Returns information about the workbooks that the specified user owns + and has Read (view) permissions for. + + This method retrieves the workbook information for the specified user. + The REST API is designed to return only the information you ask for + explicitly. When you query for all the users, the workbook information + for each user is not included. Use this method to retrieve information + about the workbooks that the user owns or has Read (view) permissions. + The method adds the list of workbooks to the user item object + (user_item.workbooks). + + Parameters + ---------- + user_item : UserItem + The user item to populate workbooks for. + + req_options : Optional[RequestOptions] + Optional request options to filter and sort the results. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the user item is missing an ID. + + Examples + -------- + >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + >>> server.users.populate_workbooks(user) + >>> for wb in user.workbooks: + >>> print(wb.name) + """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -142,11 +438,62 @@ def _get_wbs_for_user( return workbook_item, pagination_item def populate_favorites(self, user_item: UserItem) -> None: + """ + Populate the favorites for the user. + + Parameters + ---------- + user_item : UserItem + The user item to populate favorites for. + + Returns + ------- + None + + Examples + -------- + >>> import tableauserverclient as TSC + >>> server = TSC.Server('https://SERVERURL') + >>> # Login to the server + + >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + >>> server.users.populate_favorites(user) + >>> for obj_type, items in user.favorites.items(): + >>> print(f"Favorites for {obj_type}:") + >>> for item in items: + >>> print(item.name) + """ self.parent_srv.favorites.get(user_item) # Get groups for user @api(version="3.7") def populate_groups(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: + """ + Populate the groups for the user. + + Parameters + ---------- + user_item : UserItem + The user item to populate groups for. + + req_options : Optional[RequestOptions] + Optional request options to filter and sort the results. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the user item is missing an ID. + + Examples + -------- + >>> server.users.populate_groups(user) + >>> for group in user.groups: + >>> print(group.name) + """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) From 9b1b9406df55e1eee0baba7b52d5586a3854b83a Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 14 Oct 2024 11:53:12 -0500 Subject: [PATCH 16/28] ci: build on python 3.13 (#1492) Now that python 3.13 has released, test builds on actual 3.13 instead of the 3.13-dev build Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 7e1533ee..ac622795 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,7 +13,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13-dev'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] runs-on: ${{ matrix.os }} From d880d520ee1b8d5d94e3d5a9caa896286fe2a9dd Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 15 Oct 2024 02:04:22 -0500 Subject: [PATCH 17/28] docs: workbook docstrings (#1488) Add detailed docstrings to workbook item and endpoint Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/workbook_item.py | 78 +++ .../server/endpoint/workbooks_endpoint.py | 598 +++++++++++++++++- 2 files changed, 674 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index ab5ff415..776d041e 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -21,6 +21,84 @@ class WorkbookItem: + """ + The workbook resources for Tableau are defined in the WorkbookItem class. + The class corresponds to the workbook resources you can access using the + Tableau REST API. Some workbook methods take an instance of the WorkbookItem + class as arguments. The workbook item specifies the project. + + Parameters + ---------- + project_id : Optional[str], optional + The project ID for the workbook, by default None. + + name : Optional[str], optional + The name of the workbook, by default None. + + show_tabs : bool, optional + Determines whether the workbook shows tabs for the view. + + Attributes + ---------- + connections : list[ConnectionItem] + The list of data connections (ConnectionItem) for the data sources used + by the workbook. You must first call the workbooks.populate_connections + method to access this data. See the ConnectionItem class. + + content_url : Optional[str] + The name of the workbook as it appears in the URL. + + created_at : Optional[datetime.datetime] + The date and time the workbook was created. + + description : Optional[str] + User-defined description of the workbook. + + id : Optional[str] + The identifier for the workbook. You need this value to query a specific + workbook or to delete a workbook with the get_by_id and delete methods. + + owner_id : Optional[str] + The identifier for the owner (UserItem) of the workbook. + + preview_image : bytes + The thumbnail image for the view. You must first call the + workbooks.populate_preview_image method to access this data. + + project_name : Optional[str] + The name of the project that contains the workbook. + + size: int + The size of the workbook in megabytes. + + hidden_views: Optional[list[str]] + List of string names of views that need to be hidden when the workbook + is published. + + tags: set[str] + The set of tags associated with the workbook. + + updated_at : Optional[datetime.datetime] + The date and time the workbook was last updated. + + views : list[ViewItem] + The list of views (ViewItem) for the workbook. You must first call the + workbooks.populate_views method to access this data. See the ViewItem + class. + + web_page_url : Optional[str] + The full URL for the workbook. + + Examples + -------- + # creating a new instance of a WorkbookItem + >>> import tableauserverclient as TSC + + >>> # Create new workbook_item with project id '3a8b6148-493c-11e6-a621-6f3499394a39' + + >>> new_workbook = TSC.WorkbookItem('3a8b6148-493c-11e6-a621-6f3499394a39') + """ + def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, show_tabs: bool = False) -> None: self._connections = None self._content_url = None diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 5e4442b6..460017d1 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -7,6 +7,7 @@ from pathlib import Path from tableauserverclient.helpers.headers import fix_filename +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.query import QuerySet from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in @@ -69,6 +70,22 @@ def baseurl(self) -> str: # Get all workbooks on site @api(version="2.0") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WorkbookItem], PaginationItem]: + """ + Queries the server and returns information about the workbooks the site. + + Parameters + ---------- + req_options : RequestOptions, optional + (Optional) You can pass the method a request object that contains + additional parameters to filter the request. For example, if you + were searching for a specific workbook, you could specify the name + of the workbook or the name of the owner. + + Returns + ------- + Tuple containing one page's worth of workbook items and pagination + information. + """ logger.info("Querying all workbooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -79,6 +96,19 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Work # Get 1 workbook @api(version="2.0") def get_by_id(self, workbook_id: str) -> WorkbookItem: + """ + Returns information about the specified workbook on the site. + + Parameters + ---------- + workbook_id : str + The workbook ID. + + Returns + ------- + WorkbookItem + The workbook item. + """ if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) @@ -89,6 +119,19 @@ def get_by_id(self, workbook_id: str) -> WorkbookItem: @api(version="2.8") def refresh(self, workbook_item: Union[WorkbookItem, str]) -> JobItem: + """ + Refreshes the extract of an existing workbook. + + Parameters + ---------- + workbook_item : WorkbookItem | str + The workbook item or workbook ID. + + Returns + ------- + JobItem + The job item. + """ id_ = getattr(workbook_item, "id", workbook_item) url = f"{self.baseurl}/{id_}/refresh" empty_req = RequestFactory.Empty.empty_req() @@ -105,6 +148,33 @@ def create_extract( includeAll: bool = True, datasources: Optional[list["DatasourceItem"]] = None, ) -> JobItem: + """ + Create one or more extracts on 1 workbook, optionally encrypted. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_extracts_for_workbook + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to create extracts for. + + encrypt : bool, default False + Set to True to encrypt the extracts. + + includeAll : bool, default True + If True, all data sources in the workbook will have an extract + created for them. If False, then a data source must be supplied in + the request. + + datasources : list[DatasourceItem] | None + List of DatasourceItem objects for the data sources to create + extracts for. Only required if includeAll is False. + + Returns + ------- + JobItem + The job item for the extract creation. + """ id_ = getattr(workbook_item, "id", workbook_item) url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}" @@ -116,6 +186,29 @@ def create_extract( # delete all the extracts on 1 workbook @api(version="3.3") def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True, datasources=None) -> JobItem: + """ + Delete all extracts of embedded datasources on 1 workbook. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_extracts_from_workbook + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to delete extracts from. + + includeAll : bool, default True + If True, all data sources in the workbook will have their extracts + deleted. If False, then a data source must be supplied in the + request. + + datasources : list[DatasourceItem] | None + List of DatasourceItem objects for the data sources to delete + extracts from. Only required if includeAll is False. + + Returns + ------- + JobItem + """ id_ = getattr(workbook_item, "id", workbook_item) url = f"{self.baseurl}/{id_}/deleteExtract" datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) @@ -126,6 +219,18 @@ def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True, d # Delete 1 workbook by id @api(version="2.0") def delete(self, workbook_id: str) -> None: + """ + Deletes a workbook with the specified ID. + + Parameters + ---------- + workbook_id : str + The workbook ID. + + Returns + ------- + None + """ if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) @@ -141,6 +246,29 @@ def update( workbook_item: WorkbookItem, include_view_acceleration_status: bool = False, ) -> WorkbookItem: + """ + Modifies an existing workbook. Use this method to change the owner or + the project that the workbook belongs to, or to change whether the + workbook shows views in tabs. The workbook item must include the + workbook ID and overrides the existing settings. + + See https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#update_workbook + for a list of fields that can be updated. + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to update. ID is required. Other fields are + optional. Any fields that are not specified will not be changed. + + include_view_acceleration_status : bool, default False + Set to True to include the view acceleration status in the response. + + Returns + ------- + WorkbookItem + The updated workbook item. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -161,6 +289,28 @@ def update( # Update workbook_connection @api(version="2.3") def update_connection(self, workbook_item: WorkbookItem, connection_item: ConnectionItem) -> ConnectionItem: + """ + Updates a workbook connection information (server addres, server port, + user name, and password). + + The workbook connections must be populated before the strings can be + updated. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#update_workbook_connection + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to update. + + connection_item : ConnectionItem + The connection item to update. + + Returns + ------- + ConnectionItem + The updated connection item. + """ url = f"{self.baseurl}/{workbook_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) @@ -179,6 +329,34 @@ def download( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: + """ + Downloads a workbook to the specified directory (optional). + + Parameters + ---------- + workbook_id : str + The workbook ID. + + filepath : Path or File object, optional + Downloads the file to the location you specify. If no location is + specified, the file is downloaded to the current working directory. + The default is Filepath=None. + + include_extract : bool, default True + Set to False to exclude the extract from the download. The default + is True. + + Returns + ------- + Path or File object + The path to the downloaded workbook or the file object. + + Raises + ------ + ValueError + If the workbook ID is not defined. + """ + return self.download_revision( workbook_id, None, @@ -189,6 +367,36 @@ def download( # Get all views of workbook @api(version="2.0") def populate_views(self, workbook_item: WorkbookItem, usage: bool = False) -> None: + """ + Populates (or gets) a list of views for a workbook. + + You must first call this method to populate views before you can iterate + through the views. + + This method retrieves the view information for the specified workbook. + The REST API is designed to return only the information you ask for + explicitly. When you query for all the workbooks, the view information + is not included. Use this method to retrieve the views. The method adds + the list of views to the workbook item (workbook_item.views). This is a + list of ViewItem. + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate views for. + + usage : bool, default False + Set to True to include usage statistics for each view. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -214,6 +422,36 @@ def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> l # Get all connections of workbook @api(version="2.0") def populate_connections(self, workbook_item: WorkbookItem) -> None: + """ + Populates a list of data source connections for the specified workbook. + + You must populate connections before you can iterate through the + connections. + + This method retrieves the data source connection information for the + specified workbook. The REST API is designed to return only the + information you ask for explicitly. When you query all the workbooks, + the data source connection information is not included. Use this method + to retrieve the connection information for any data sources used by the + workbook. The method adds the list of data connections to the workbook + item (workbook_item.connections). This is a list of ConnectionItem. + + REST API docs: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_workbook_connections + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate connections for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -235,6 +473,34 @@ def _get_workbook_connections( # Get the pdf of the entire workbook if its tabs are enabled, pdf of the default view if its tabs are disabled @api(version="3.4") def populate_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: + """ + Populates the PDF for the specified workbook item. + + This method populates a PDF with image(s) of the workbook view(s) you + specify. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#download_workbook_pdf + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate the PDF for. + + req_options : RequestOptions, optional + (Optional) You can pass in request options to specify the page type + and orientation of the PDF content, as well as the maximum age of + the PDF rendered on the server. See PDFRequestOptions class for more + details. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID." raise MissingRequiredFieldError(error) @@ -253,6 +519,36 @@ def _get_wb_pdf(self, workbook_item: WorkbookItem, req_options: Optional["Reques @api(version="3.8") def populate_powerpoint(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: + """ + Populates the PowerPoint for the specified workbook item. + + This method populates a PowerPoint with image(s) of the workbook view(s) you + specify. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#download_workbook_powerpoint + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate the PDF for. + + req_options : RequestOptions, optional + (Optional) You can pass in request options to specify the maximum + number of minutes a workbook .pptx will be cached before being + refreshed. To prevent multiple .pptx requests from overloading the + server, the shortest interval you can set is one minute. There is no + maximum value, but the server job enacting the caching action may + expire before a long cache period is reached. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID." raise MissingRequiredFieldError(error) @@ -272,6 +568,26 @@ def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["Reque # Get preview image of workbook @api(version="2.0") def populate_preview_image(self, workbook_item: WorkbookItem) -> None: + """ + This method gets the preview image (thumbnail) for the specified workbook item. + + This method uses the workbook's ID to get the preview image. The method + adds the preview image to the workbook item (workbook_item.preview_image). + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate the preview image for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -290,14 +606,65 @@ def _get_wb_preview_image(self, workbook_item: WorkbookItem) -> bytes: @api(version="2.0") def populate_permissions(self, item: WorkbookItem) -> None: + """ + Populates the permissions for the specified workbook item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_workbook_permissions + + Parameters + ---------- + item : WorkbookItem + The workbook item to populate permissions for. + + Returns + ------- + None + """ self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, resource, rules): + def update_permissions(self, resource: WorkbookItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + """ + Updates the permissions for the specified workbook item. The method + replaces the existing permissions with the new permissions. Any missing + permissions are removed. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_permissions_for_content + + Parameters + ---------- + resource : WorkbookItem + The workbook item to update permissions for. + + rules : list[PermissionsRule] + A list of permissions rules to apply to the workbook item. + + Returns + ------- + list[PermissionsRule] + The updated permissions rules. + """ return self._permissions.update(resource, rules) @api(version="2.0") - def delete_permission(self, item, capability_item): + def delete_permission(self, item: WorkbookItem, capability_item: PermissionsRule) -> None: + """ + Deletes a single permission rule from the specified workbook item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_workbook_permission + + Parameters + ---------- + item : WorkbookItem + The workbook item to delete the permission from. + + capability_item : PermissionsRule + The permission rule to delete. + + Returns + ------- + None + """ return self._permissions.delete(item, capability_item) @api(version="2.0") @@ -313,6 +680,83 @@ def publish( skip_connection_check: bool = False, parameters=None, ): + """ + Publish a workbook to the specified site. + + Note: The REST API cannot automatically include extracts or other + resources that the workbook uses. Therefore, a .twb file that uses data + from an Excel or csv file on a local computer cannot be published, + unless you package the data and workbook in a .twbx file, or publish the + data source separately. + + For workbooks that are larger than 64 MB, the publish method + automatically takes care of chunking the file in parts for uploading. + Using this method is considerably more convenient than calling the + publish REST APIs directly. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#publish_workbook + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook_item specifies the workbook you are publishing. When + you are adding a workbook, you need to first create a new instance + of a workbook_item that includes a project_id of an existing + project. The name of the workbook will be the name of the file, + unless you also specify a name for the new workbook when you create + the instance. + + file : Path or File object + The file path or file object of the workbook to publish. When + providing a file object, you must also specifiy the name of the + workbook in your instance of the workbook_itemworkbook_item , as + the name cannot be derived from the file name. + + mode : str + Specifies whether you are publishing a new workbook (CreateNew) or + overwriting an existing workbook (Overwrite). You cannot appending + workbooks. You can also use the publish mode attributes, for + example: TSC.Server.PublishMode.Overwrite. + + connections : list[ConnectionItem] | None + List of ConnectionItems objects for the connections created within + the workbook. + + as_job : bool, default False + Set to True to run the upload as a job (asynchronous upload). If set + to True a job will start to perform the publishing process and a Job + object is returned. Defaults to False. + + skip_connection_check : bool, default False + Set to True to skip connection check at time of upload. Publishing + will succeed but unchecked connection issues may result in a + non-functioning workbook. Defaults to False. + + Raises + ------ + OSError + If the file path does not lead to an existing file. + + ServerResponseError + If the server response is not successful. + + TypeError + If the file is not a file path or file object. + + ValueError + If the file extension is not supported + + ValueError + If the mode is invalid. + + ValueError + Workbooks cannot be appended. + + Returns + ------- + WorkbookItem | JobItem + The workbook item or job item that was published. + """ if isinstance(file, (str, os.PathLike)): if not os.path.isfile(file): error = "File path does not lead to an existing file." @@ -419,6 +863,28 @@ def publish( # Populate workbook item's revisions @api(version="2.3") def populate_revisions(self, workbook_item: WorkbookItem) -> None: + """ + Populates (or gets) a list of revisions for a workbook. + + You must first call this method to populate revisions before you can + iterate through the revisions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_workbook_revisions + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate revisions for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -446,6 +912,40 @@ def download_revision( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: + """ + Downloads a workbook revision to the specified directory (optional). + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#download_workbook_revision + + Parameters + ---------- + workbook_id : str + The workbook ID. + + revision_number : str | None + The revision number of the workbook. If None, the latest revision is + downloaded. + + filepath : Path or File object, optional + Downloads the file to the location you specify. If no location is + specified, the file is downloaded to the current working directory. + The default is Filepath=None. + + include_extract : bool, default True + Set to False to exclude the extract from the download. The default + is True. + + Returns + ------- + Path or File object + The path to the downloaded workbook or the file object. + + Raises + ------ + ValueError + If the workbook ID is not defined. + """ + if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) @@ -479,6 +979,28 @@ def download_revision( @api(version="2.3") def delete_revision(self, workbook_id: str, revision_number: str) -> None: + """ + Deletes a specific revision from a workbook on Tableau Server. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_revisions.htm#remove_workbook_revision + + Parameters + ---------- + workbook_id : str + The workbook ID. + + revision_number : str + The revision number of the workbook to delete. + + Returns + ------- + None + + Raises + ------ + ValueError + If the workbook ID or revision number is not defined. + """ if workbook_id is None or revision_number is None: raise ValueError url = "/".join([self.baseurl, workbook_id, "revisions", revision_number]) @@ -491,18 +1013,90 @@ def delete_revision(self, workbook_id: str, revision_number: str) -> None: def schedule_extract_refresh( self, schedule_id: str, item: WorkbookItem ) -> list["AddResponse"]: # actually should return a task + """ + Adds a workbook to a schedule for extract refresh. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_workbook_to_schedule + + Parameters + ---------- + schedule_id : str + The schedule ID. + + item : WorkbookItem + The workbook item to add to the schedule. + + Returns + ------- + list[AddResponse] + The response from the server. + """ return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) @api(version="1.0") def add_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> set[str]: + """ + Adds tags to a workbook. One or more tags may be added at a time. If a + tag already exists on the workbook, it will not be duplicated. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#add_tags_to_workbook + + Parameters + ---------- + item : WorkbookItem | str + The workbook item or workbook ID to add tags to. + + tags : Iterable[str] | str + The tag or tags to add to the workbook. Tags can be a single tag or + a list of tags. + + Returns + ------- + set[str] + The set of tags added to the workbook. + """ return super().add_tags(item, tags) @api(version="1.0") def delete_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> None: + """ + Deletes tags from a workbook. One or more tags may be deleted at a time. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#delete_tag_from_workbook + + Parameters + ---------- + item : WorkbookItem | str + The workbook item or workbook ID to delete tags from. + + tags : Iterable[str] | str + The tag or tags to delete from the workbook. Tags can be a single + tag or a list of tags. + + Returns + ------- + None + """ return super().delete_tags(item, tags) @api(version="1.0") def update_tags(self, item: WorkbookItem) -> None: + """ + Updates the tags on a workbook. This method is used to update the tags + on the server to match the tags on the workbook item. This method is a + convenience method that calls add_tags and delete_tags to update the + tags on the server. + + Parameters + ---------- + item : WorkbookItem + The workbook item to update the tags for. The tags on the workbook + item will be used to update the tags on the server. + + Returns + ------- + None + """ return super().update_tags(item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[WorkbookItem]: From 9f59af159436a8f6b0da2f4155d8a9f4d37d2766 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 15 Oct 2024 02:05:50 -0500 Subject: [PATCH 18/28] chore: type hint default permissions endpoints (#1493) Resource is not currently an actual type, but an enum-like holder for literal values. Added a Union for str types to make mypy happy. Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/tableau_types.py | 2 +- .../endpoint/default_permissions_endpoint.py | 10 ++- .../server/endpoint/projects_endpoint.py | 73 +++++++++++-------- 3 files changed, 51 insertions(+), 34 deletions(-) diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index ea2a5e4f..01ee3d3a 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -28,7 +28,7 @@ class Resource: TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem, VirtualConnectionItem] -def plural_type(content_type: Resource) -> str: +def plural_type(content_type: Union[Resource, str]) -> str: if content_type == Resource.Lens: return "lenses" else: diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 343d8b09..499324e8 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -39,7 +39,7 @@ def __str__(self): __repr__ = __str__ def update_default_permissions( - self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Resource + self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Union[Resource, str] ) -> list[PermissionsRule]: url = f"{self.owner_baseurl()}/{resource.id}/default-permissions/{plural_type(content_type)}" update_req = RequestFactory.Permission.add_req(permissions) @@ -50,7 +50,9 @@ def update_default_permissions( return permissions - def delete_default_permission(self, resource: BaseItem, rule: PermissionsRule, content_type: Resource) -> None: + def delete_default_permission( + self, resource: BaseItem, rule: PermissionsRule, content_type: Union[Resource, str] + ) -> None: for capability, mode in rule.capabilities.items(): # Made readability better but line is too long, will make this look better url = ( @@ -72,7 +74,7 @@ def delete_default_permission(self, resource: BaseItem, rule: PermissionsRule, c logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}") - def populate_default_permissions(self, item: BaseItem, content_type: Resource) -> None: + def populate_default_permissions(self, item: BaseItem, content_type: Union[Resource, str]) -> None: if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -84,7 +86,7 @@ def permission_fetcher() -> list[PermissionsRule]: logger.info(f"Populated default {content_type} permissions for item (ID: {item.id})") def _get_default_permissions( - self, item: BaseItem, content_type: Resource, req_options: Optional["RequestOptions"] = None + self, item: BaseItem, content_type: Union[Resource, str], req_options: Optional["RequestOptions"] = None ) -> list[PermissionsRule]: url = f"{self.owner_baseurl()}/{item.id}/default-permissions/{plural_type(content_type)}" server_response = self.get_request(url, req_options) diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 773b942d..74bb865c 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -5,6 +5,7 @@ from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server import RequestFactory, RequestOptions +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.models import ProjectItem, PaginationItem, Resource from typing import Optional, TYPE_CHECKING @@ -78,119 +79,133 @@ 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): + 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): + 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): + 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, rules): + 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, rules): + 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, rules): + 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, rule): + 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, rule): + 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, rule): + 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]: From 2ff96971ef7be58850121cca398111cc7810cec5 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 17 Oct 2024 00:00:58 -0500 Subject: [PATCH 19/28] fix: handle 0 item response in querysets (#1501) * fix: handle 0 item response in querysets A flaw in the __iter__ logic introduced to handle scenarios where a pagination element is not included in the response xml resulted in an infinite loop. This PR introduces a few changes to protect against this: 1. After running QuerySet._fetch_all(), if the result_cache is empty, return instead of performing other comparisons. 2. Ensure that any non-None total_available is returned from the PaginationItem's object. 3. In _fetch_all, check if there is a PaginationItem that has been populated so as to not call the server side endpoint muliple times before returning. * fix: null out PaginationItem._page_number Tests were failing because the fetch_all method added a second check before fetching the next page. This fix will allow the next page to be retrieved when used normally --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/server/query.py | 8 ++++++-- test/test_pager.py | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index feebc1a7..801ad4a1 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -77,6 +77,7 @@ def __iter__(self: Self) -> Iterator[T]: for page in count(1): self.request_options.pagenumber = page self._result_cache = [] + self._pagination_item._page_number = None try: self._fetch_all() except ServerResponseError as e: @@ -85,6 +86,8 @@ def __iter__(self: Self) -> Iterator[T]: # up overrunning the total number of pages. Catch the # error and break out of the loop. raise StopIteration + if len(self._result_cache) == 0: + return yield from self._result_cache # If the length of the QuerySet is unknown, continue fetching until # the result cache is empty. @@ -139,6 +142,7 @@ def __getitem__(self, k): elif k in range(self.total_available): # Otherwise, check if k is even sensible to return self._result_cache = [] + self._pagination_item._page_number = None # Add one to k, otherwise it gets stuck at page boundaries, e.g. 100 self.request_options.pagenumber = max(1, math.ceil((k + 1) / size)) return self[k] @@ -150,7 +154,7 @@ def _fetch_all(self: Self) -> None: """ Retrieve the data and store result and pagination item in cache """ - if not self._result_cache: + if not self._result_cache and self._pagination_item._page_number is None: response = self.model.get(self.request_options) if isinstance(response, tuple): self._result_cache, self._pagination_item = response @@ -159,7 +163,7 @@ def _fetch_all(self: Self) -> None: self._pagination_item = PaginationItem() def __len__(self: Self) -> int: - return self.total_available or sys.maxsize + return sys.maxsize if self.total_available is None else self.total_available @property def total_available(self: Self) -> int: diff --git a/test/test_pager.py b/test/test_pager.py index c3035280..1836095b 100644 --- a/test/test_pager.py +++ b/test/test_pager.py @@ -1,6 +1,7 @@ import contextlib import os import unittest +import xml.etree.ElementTree as ET import requests_mock @@ -122,3 +123,14 @@ def test_pager_view(self) -> None: m.get(self.server.views.baseurl, text=view_xml) for view in TSC.Pager(self.server.views): assert view.name is not None + + def test_queryset_no_matches(self) -> None: + elem = ET.Element("tsResponse", xmlns="http://tableau.com/api") + ET.SubElement(elem, "pagination", totalAvailable="0") + ET.SubElement(elem, "groups") + xml = ET.tostring(elem).decode("utf-8") + with requests_mock.mock() as m: + m.get(self.server.groups.baseurl, text=xml) + all_groups = self.server.groups.all() + groups = list(all_groups) + assert len(groups) == 0 From e623511f67f9952d252716e3792808760552cd67 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 17 Oct 2024 00:39:08 -0500 Subject: [PATCH 20/28] ci: cache dependencies for faster builds (#1497) * ci: cache dependencies for faster builds * ci: cache for mypy and black --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .github/workflows/meta-checks.yml | 14 ++++++++++++++ .github/workflows/run-tests.yml | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml index 41a944e6..0e2b425e 100644 --- a/.github/workflows/meta-checks.yml +++ b/.github/workflows/meta-checks.yml @@ -13,6 +13,20 @@ jobs: runs-on: ${{ matrix.os }} steps: + - name: Get pip cache dir + id: pip-cache + shell: bash + run: | + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT + + - name: cache + uses: actions/cache@v4 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.python-version }}-pip- + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index ac622795..2e197cf2 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -18,6 +18,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 }} From c361f8f7e0dc57da3dc6542addc843db237c506a Mon Sep 17 00:00:00 2001 From: renoyjohnm <168143499+renoyjohnm@users.noreply.github.com> Date: Tue, 22 Oct 2024 11:33:06 -0700 Subject: [PATCH 21/28] Feature: export custom views #999 (#1506) Adding custom views PDF & CSV export endpoints --- samples/export.py | 5 ++ .../models/custom_view_item.py | 25 +++++- .../server/endpoint/custom_views_endpoint.py | 52 +++++++++++- tableauserverclient/server/request_options.py | 80 +++++++++++-------- test/test_custom_view.py | 72 +++++++++++++++++ 5 files changed, 194 insertions(+), 40 deletions(-) diff --git a/samples/export.py b/samples/export.py index e3371046..b2506cf4 100644 --- a/samples/export.py +++ b/samples/export.py @@ -41,6 +41,7 @@ def main(): "--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") @@ -58,6 +59,8 @@ def main(): print("Connected") if args.workbook: item = server.workbooks.get_by_id(args.resource_id) + elif args.custom_view: + item = server.custom_views.get_by_id(args.resource_id) else: item = server.views.get_by_id(args.resource_id) @@ -74,6 +77,8 @@ 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() diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py index de917bf4..a0c0a984 100644 --- a/tableauserverclient/models/custom_view_item.py +++ b/tableauserverclient/models/custom_view_item.py @@ -3,6 +3,7 @@ from defusedxml import ElementTree from defusedxml.ElementTree import fromstring, tostring from typing import Callable, Optional +from collections.abc import Iterator from .exceptions import UnpopulatedPropertyError from .user_item import UserItem @@ -17,6 +18,8 @@ def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None self._created_at: Optional["datetime"] = None self._id: Optional[str] = id self._image: Optional[Callable[[], bytes]] = None + self._pdf: Optional[Callable[[], bytes]] = None + self._csv: Optional[Callable[[], Iterator[bytes]]] = None self._name: Optional[str] = name self._shared: Optional[bool] = False self._updated_at: Optional["datetime"] = None @@ -40,6 +43,12 @@ def __repr__(self: "CustomViewItem"): def _set_image(self, image): self._image = image + def _set_pdf(self, pdf): + self._pdf = pdf + + def _set_csv(self, csv): + self._csv = csv + @property def content_url(self) -> Optional[str]: return self._content_url @@ -55,10 +64,24 @@ def id(self) -> Optional[str]: @property def image(self) -> bytes: if self._image is None: - error = "View item must be populated with its png image first." + error = "Custom View item must be populated with its png image first." raise UnpopulatedPropertyError(error) return self._image() + @property + def pdf(self) -> bytes: + if self._pdf is None: + error = "Custom View item must be populated with its pdf first." + raise UnpopulatedPropertyError(error) + return self._pdf() + + @property + def csv(self) -> Iterator[bytes]: + if self._csv is None: + error = "Custom View item must be populated with its csv first." + raise UnpopulatedPropertyError(error) + return self._csv() + @property def name(self) -> Optional[str]: return self._name diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index 63899ba0..b02b05d7 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -1,15 +1,23 @@ import io import logging import os +from contextlib import closing from pathlib import Path from typing import Optional, Union +from collections.abc import Iterator from tableauserverclient.config import BYTES_PER_MB, config from tableauserverclient.filesys_helpers import get_file_object_size from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.models import CustomViewItem, PaginationItem -from tableauserverclient.server import RequestFactory, RequestOptions, ImageRequestOptions +from tableauserverclient.server import ( + RequestFactory, + RequestOptions, + ImageRequestOptions, + PDFRequestOptions, + CSVRequestOptions, +) from tableauserverclient.helpers.logging import logger @@ -91,9 +99,45 @@ def _get_view_image(self, view_item: CustomViewItem, req_options: Optional["Imag image = server_response.content return image - """ - Not yet implemented: pdf or csv exports - """ + @api(version="3.23") + def populate_pdf(self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"] = None) -> None: + if not custom_view_item.id: + error = "Custom View item missing ID." + raise MissingRequiredFieldError(error) + + def pdf_fetcher(): + return self._get_custom_view_pdf(custom_view_item, req_options) + + custom_view_item._set_pdf(pdf_fetcher) + logger.info(f"Populated pdf for custom view (ID: {custom_view_item.id})") + + def _get_custom_view_pdf( + self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"] + ) -> bytes: + url = f"{self.baseurl}/{custom_view_item.id}/pdf" + server_response = self.get_request(url, req_options) + pdf = server_response.content + return pdf + + @api(version="3.23") + def populate_csv(self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None: + if not custom_view_item.id: + error = "Custom View item missing ID." + raise MissingRequiredFieldError(error) + + def csv_fetcher(): + return self._get_custom_view_csv(custom_view_item, req_options) + + custom_view_item._set_csv(csv_fetcher) + logger.info(f"Populated csv for custom view (ID: {custom_view_item.id})") + + def _get_custom_view_csv( + self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"] + ) -> Iterator[bytes]: + url = f"{self.baseurl}/{custom_view_item.id}/data" + + with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: + yield from server_response.iter_content(1024) @api(version="3.18") def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 0d47abfc..d79ac7f7 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -213,6 +213,46 @@ 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 + + @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") + + if self.viz_height is not None: + params["vizHeight"] = self.viz_height + + if self.viz_width is not None: + params["vizWidth"] = self.viz_width + + return params + + class CSVRequestOptions(_DataExportOptions): extension = "csv" @@ -221,15 +261,15 @@ class ExcelRequestOptions(_DataExportOptions): extension = "xlsx" -class ImageRequestOptions(_DataExportOptions): +class ImageRequestOptions(_ImagePDFCommonExportOptions): extension = "png" # if 'high' isn't specified, the REST API endpoint returns an image with standard resolution class Resolution: High = "high" - def __init__(self, imageresolution=None, maxage=-1): - super().__init__(maxage=maxage) + 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 def get_query_params(self): @@ -239,7 +279,7 @@ def get_query_params(self): return params -class PDFRequestOptions(_DataExportOptions): +class PDFRequestOptions(_ImagePDFCommonExportOptions): class PageType: A3 = "a3" A4 = "a4" @@ -261,29 +301,9 @@ class Orientation: Landscape = "landscape" def __init__(self, page_type=None, orientation=None, maxage=-1, viz_height=None, viz_width=None): - super().__init__(maxage=maxage) + super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width) self.page_type = page_type self.orientation = orientation - self.viz_height = viz_height - self.viz_width = viz_width - - @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() @@ -293,14 +313,4 @@ def get_query_params(self) -> dict: if self.orientation: params["orientation"] = self.orientation - # 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 - return params diff --git a/test/test_custom_view.py b/test/test_custom_view.py index 80800c86..6e863a86 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -18,6 +18,8 @@ GET_XML_ID = os.path.join(TEST_ASSET_DIR, "custom_view_get_id.xml") POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, "Sample View Image.png") CUSTOM_VIEW_UPDATE_XML = os.path.join(TEST_ASSET_DIR, "custom_view_update.xml") +CUSTOM_VIEW_POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf") +CUSTOM_VIEW_POPULATE_CSV = os.path.join(TEST_ASSET_DIR, "populate_csv.csv") CUSTOM_VIEW_DOWNLOAD = TEST_ASSET_DIR / "custom_view_download.json" FILE_UPLOAD_INIT = TEST_ASSET_DIR / "fileupload_initialize.xml" FILE_UPLOAD_APPEND = TEST_ASSET_DIR / "fileupload_append.xml" @@ -246,3 +248,73 @@ def test_large_publish(self): assert isinstance(view, TSC.CustomViewItem) assert view.id is not None assert view.name is not None + + def test_populate_pdf(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.custom_views.baseurl + with open(CUSTOM_VIEW_POPULATE_PDF, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get( + self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?type=letter&orientation=portrait&maxAge=5", + content=response, + ) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + size = TSC.PDFRequestOptions.PageType.Letter + orientation = TSC.PDFRequestOptions.Orientation.Portrait + req_option = TSC.PDFRequestOptions(size, orientation, 5) + + self.server.custom_views.populate_pdf(custom_view, req_option) + self.assertEqual(response, custom_view.pdf) + + def test_populate_csv(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.custom_views.baseurl + with open(CUSTOM_VIEW_POPULATE_CSV, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data?maxAge=1", content=response) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + request_option = TSC.CSVRequestOptions(maxage=1) + self.server.custom_views.populate_csv(custom_view, request_option) + + csv_file = b"".join(custom_view.csv) + self.assertEqual(response, csv_file) + + def test_populate_csv_default_maxage(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.custom_views.baseurl + with open(CUSTOM_VIEW_POPULATE_CSV, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data", content=response) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + self.server.custom_views.populate_csv(custom_view) + + csv_file = b"".join(custom_view.csv) + self.assertEqual(response, csv_file) + + def test_pdf_height(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.custom_views.baseurl + with open(CUSTOM_VIEW_POPULATE_PDF, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get( + self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?vizHeight=1080&vizWidth=1920", + content=response, + ) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + req_option = TSC.PDFRequestOptions( + viz_height=1080, + viz_width=1920, + ) + + self.server.custom_views.populate_pdf(custom_view, req_option) + self.assertEqual(response, custom_view.pdf) From 607fa8b6b1ead27bb128c9dc0d97a1c0e53b1955 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 22 Oct 2024 15:59:16 -0500 Subject: [PATCH 22/28] chore: remove py2 holdover code (#1496) Favor list comprehensions for readability, consistency, and performance Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/server/endpoint/schedules_endpoint.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 4ed243b2..eec4536f 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -115,8 +115,7 @@ def add_to_schedule( ) # type:ignore[arg-type] results = (self._add_to(*x) for x in items) - # list() is needed for python 3.x compatibility - return list(filter(lambda x: not x.result, results)) # type:ignore[arg-type] + return [x for x in results if not x.result] def _add_to( self, From 60dfd4d293920cffeb194ae6e3e27ac5bea83694 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 22 Oct 2024 16:38:46 -0700 Subject: [PATCH 23/28] Update samples for Python 3.x compatibility (#1479) * Replace obsolete env package with os.environ * Python 2.x to 3.x updates * Fix some comments * Remove workbook data acceleration; feature was removed in 2022 * Remove switch_site() example which is confusing in this context of demonstrating login --- samples/extracts.py | 12 +- samples/login.py | 21 ++-- samples/publish_datasource.py | 23 ++-- samples/set_refresh_schedule.py | 2 +- samples/update_connection.py | 2 +- samples/update_workbook_data_acceleration.py | 109 ------------------- 6 files changed, 32 insertions(+), 137 deletions(-) delete mode 100644 samples/update_workbook_data_acceleration.py diff --git a/samples/extracts.py b/samples/extracts.py index d21bfdd0..c0dd885b 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -1,13 +1,7 @@ #### -# This script demonstrates how to use the Tableau Server Client -# to interact with workbooks. It explores the different -# functions that the Server API supports on workbooks. -# -# With no flags set, this sample will query all workbooks, -# pick one workbook and populate its connections/views, and update -# the workbook. Adding flags will demonstrate the specific feature -# on top of the general operations. -#### +# This script demonstrates how to use the Tableau Server Client to interact with extracts. +# It explores the different functions that the REST API supports on extracts. +##### import argparse import logging diff --git a/samples/login.py b/samples/login.py index 847d3558..bc99385b 100644 --- a/samples/login.py +++ b/samples/login.py @@ -7,9 +7,15 @@ import argparse import getpass import logging +import os import tableauserverclient as TSC -import env + + +def get_env(key): + if key in os.environ: + return os.environ[key] + return None # If a sample has additional arguments, then it should copy this code and insert them after the call to @@ -20,13 +26,13 @@ def set_up_and_log_in(): sample_define_common_options(parser) args = parser.parse_args() if not args.server: - args.server = env.server + args.server = get_env("SERVER") if not args.site: - args.site = env.site + args.site = get_env("SITE") if not args.token_name: - args.token_name = env.token_name + args.token_name = get_env("TOKEN_NAME") if not args.token_value: - args.token_value = env.token_value + args.token_value = get_env("TOKEN_VALUE") args.logging_level = "debug" server = sample_connect_to_server(args) @@ -79,10 +85,7 @@ def sample_connect_to_server(args): # Make sure we use an updated version of the rest apis, and pass in our cert handling choice server = TSC.Server(args.server, use_server_version=True, http_options={"verify": check_ssl_certificate}) server.auth.sign_in(tableau_auth) - server.version = "2.6" - new_site: TSC.SiteItem = TSC.SiteItem("cdnear", content_url=env.site) - server.auth.switch_site(new_site) - print("Logged in successfully") + server.version = "3.19" return server diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index 85f63fb3..c674e688 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -21,12 +21,17 @@ import argparse import logging +import os import tableauserverclient as TSC - -import env import tableauserverclient.datetime_helpers +def get_env(key): + if key in os.environ: + return os.environ[key] + return None + + def main(): parser = argparse.ArgumentParser(description="Publish a datasource to server.") # Common options; please keep those in sync across all samples @@ -52,13 +57,13 @@ def main(): args = parser.parse_args() if not args.server: - args.server = env.server + args.server = get_env("SERVER") if not args.site: - args.site = env.site + args.site = get_env("SITE") if not args.token_name: - args.token_name = env.token_name + args.token_name = get_env("TOKEN_NAME") if not args.token_value: - args.token_value = env.token_value + args.token_value = get_env("TOKEN_VALUE") args.logging = "debug" args.file = "C:/dev/tab-samples/5M.tdsx" args.async_ = True @@ -118,8 +123,10 @@ def main(): new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds ) print( - "{}Datasource published. Datasource ID: {}".format( - new_datasource.id, tableauserverclient.datetime_helpers.timestamp() + ( + "{}Datasource published. Datasource ID: {}".format( + new_datasource.id, tableauserverclient.datetime_helpers.timestamp() + ) ) ) print("\t\tClosing connection") diff --git a/samples/set_refresh_schedule.py b/samples/set_refresh_schedule.py index 56fd12e6..153bb0ee 100644 --- a/samples/set_refresh_schedule.py +++ b/samples/set_refresh_schedule.py @@ -38,7 +38,7 @@ def usage(args): def make_filter(**kwargs): options = TSC.RequestOptions() - for item, value in kwargs.items(): + for item, value in list(kwargs.items()): name = getattr(TSC.RequestOptions.Field, item) options.filter.add(TSC.Filter(name, TSC.RequestOptions.Operator.Equals, value)) return options diff --git a/samples/update_connection.py b/samples/update_connection.py index 4af6592b..0fe2f342 100644 --- a/samples/update_connection.py +++ b/samples/update_connection.py @@ -45,7 +45,7 @@ def main(): update_function = endpoint.update_connection resource = endpoint.get_by_id(args.resource_id) endpoint.populate_connections(resource) - connections = list(filter(lambda x: x.id == args.connection_id, resource.connections)) + connections = list([x for x in resource.connections if x.id == args.connection_id]) assert len(connections) == 1 connection = connections[0] connection.username = args.datasource_username diff --git a/samples/update_workbook_data_acceleration.py b/samples/update_workbook_data_acceleration.py deleted file mode 100644 index 57a1363e..00000000 --- a/samples/update_workbook_data_acceleration.py +++ /dev/null @@ -1,109 +0,0 @@ -#### -# This script demonstrates how to update workbook data acceleration using the Tableau -# Server Client. -# -# To run the script, you must have installed Python 3.7 or later. -#### - - -import argparse -import logging - -import tableauserverclient as TSC -from tableauserverclient import IntervalItem - - -def main(): - parser = argparse.ArgumentParser(description="Creates sample schedules for each type of frequency.") - # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", help="server address") - parser.add_argument("--site", "-S", help="site name") - parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") - parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") - parser.add_argument( - "--logging-level", - "-l", - choices=["debug", "info", "error"], - default="error", - help="desired logging level (set to error by default)", - ) - # Options specific to this sample: - # This sample has no additional options, yet. If you add some, please add them here - - args = parser.parse_args() - - # Set logging level based on user input, or error by default - logging_level = getattr(logging, args.logging_level.upper()) - logging.basicConfig(level=logging_level) - - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=False) - server.add_http_options({"verify": False}) - server.use_server_version() - with server.auth.sign_in(tableau_auth): - # Get workbook - all_workbooks, pagination_item = server.workbooks.get() - print(f"\nThere are {pagination_item.total_available} workbooks on site: ") - print([workbook.name for workbook in all_workbooks]) - - if all_workbooks: - # Pick 1 workbook to try data acceleration. - # Note that data acceleration has a couple of requirements, please check the Tableau help page - # to verify your workbook/view is eligible for data acceleration. - - # Assuming 1st workbook is eligible for sample purposes - sample_workbook = all_workbooks[2] - - # Enable acceleration for all the views in the workbook - enable_config = dict() - enable_config["acceleration_enabled"] = True - enable_config["accelerate_now"] = True - - sample_workbook.data_acceleration_config = enable_config - updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) - # Since we did not set any specific view, we will enable all views in the workbook - print("Enable acceleration for all the views in the workbook " + updated.name + ".") - - # Disable acceleration on one of the view in the workbook - # You have to populate_views first, then set the views of the workbook - # to the ones you want to update. - server.workbooks.populate_views(sample_workbook) - view_to_disable = sample_workbook.views[0] - sample_workbook.views = [view_to_disable] - - disable_config = dict() - disable_config["acceleration_enabled"] = False - disable_config["accelerate_now"] = True - - sample_workbook.data_acceleration_config = disable_config - # To get the acceleration status on the response, set includeViewAccelerationStatus=true - # Note that you have to populate_views first to get the acceleration status, since - # acceleration status is per view basis (not per workbook) - updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook, True) - view1 = updated.views[0] - print('Disabled acceleration for 1 view "' + view1.name + '" in the workbook ' + updated.name + ".") - - # Get acceleration status of the views in workbook using workbooks.get_by_id - # This won't need to do populate_views beforehand - my_workbook = server.workbooks.get_by_id(sample_workbook.id) - view1 = my_workbook.views[0] - view2 = my_workbook.views[1] - print( - "Fetching acceleration status for views in the workbook " - + updated.name - + ".\n" - + 'View "' - + view1.name - + '" has acceleration_status = ' - + view1.data_acceleration_config["acceleration_status"] - + ".\n" - + 'View "' - + view2.name - + '" has acceleration_status = ' - + view2.data_acceleration_config["acceleration_status"] - + "." - ) - - -if __name__ == "__main__": - main() From 63ece8235a1febcb58edd881f3fa91bac52836f2 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 22 Oct 2024 18:54:42 -0500 Subject: [PATCH 24/28] chore: support VizqlDataApiAccess capability (#1504) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/permissions_item.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 186cebed..bb348727 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -36,6 +36,7 @@ class Capability: ShareView = "ShareView" ViewComments = "ViewComments" ViewUnderlyingData = "ViewUnderlyingData" + VizqlDataApiAccess = "VizqlDataApiAccess" WebAuthoring = "WebAuthoring" Write = "Write" RunExplainData = "RunExplainData" From b65d8d4ab5a05d575676c5034bc40efffe425f3d Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 22 Oct 2024 17:07:31 -0700 Subject: [PATCH 25/28] Remove sample code showing group name encoding (#1486) * Remove sample code showing group name encoding This is no longer needed - ran the sample and verified that it works now. --- samples/filter_sort_groups.py | 44 ++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index d967659a..1694bf0f 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -47,7 +47,7 @@ def main(): logging.basicConfig(level=logging_level) tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=True) + server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False}) with server.auth.sign_in(tableau_auth): group_name = "SALES NORTHWEST" # Try to create a group named "SALES NORTHWEST" @@ -57,37 +57,36 @@ def main(): # Try to create a group named "SALES ROMANIA" create_example_group(group_name, server) - # URL Encode the name of the group that we want to filter on - # i.e. turn spaces into plus signs - filter_group_name = urllib.parse.quote_plus(group_name) + # we no longer need to encode the space options = TSC.RequestOptions() - options.filter.add( - TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, filter_group_name) - ) + options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, group_name)) filtered_groups, _ = server.groups.get(req_options=options) # Result can either be a matching group or an empty list if filtered_groups: - group_name = filtered_groups.pop().name - print(group_name) + group = filtered_groups.pop() + print(group) else: - error = f"No project named '{filter_group_name}' found" + error = f"No group named '{group_name}' found" print(error) + print("---") + # Or, try the above with the django style filtering try: - group = server.groups.filter(name=filter_group_name)[0] + group = server.groups.filter(name=group_name)[0] + print(group) except IndexError: - print(f"No project named '{filter_group_name}' found") - else: - print(group.name) + print(f"No group named '{group_name}' found") + + print("====") options = TSC.RequestOptions() options.filter.add( TSC.Filter( TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, - ["SALES+NORTHWEST", "SALES+ROMANIA", "this_group"], + ["SALES NORTHWEST", "SALES ROMANIA", "this_group"], ) ) @@ -98,13 +97,20 @@ def main(): for group in matching_groups: print(group.name) + print("----") # or, try the above with the django style filtering. - - groups = ["SALES NORTHWEST", "SALES ROMANIA", "this_group"] - groups = [urllib.parse.quote_plus(group) for group in groups] - for group in server.groups.filter(name__in=groups).sort("-name"): + all_g = server.groups.all() + print(f"Searching locally among {all_g.total_available} groups") + for a in all_g: + print(a) + groups = [urllib.parse.quote_plus(group) for group in ["SALES NORTHWEST", "SALES ROMANIA", "this_group"]] + print(groups) + + for group in server.groups.filter(name__in=groups).order_by("-name"): print(group.name) + print("done") + if __name__ == "__main__": main() From 3e3837267bd1c2d24d3d49d1ec017755d264ed00 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 23 Oct 2024 10:19:08 -0700 Subject: [PATCH 26/28] Update requests library for CVE CVE-2024-35195 (#1507) Update pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 67faefbe..08f90c49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ readme = "README.md" dependencies = [ 'defusedxml>=0.7.1', # latest as at 7/31/23 'packaging>=23.1', # latest as at 7/31/23 - 'requests>=2.31', # latest as at 7/31/23 + 'requests>=2.32', # latest as at 7/31/23 'urllib3>=2.2.2,<3', 'typing_extensions>=4.0.1', ] From 878d5934759939a9bd79689c2b8c3c7a1cee024f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Wed, 23 Oct 2024 12:19:27 -0500 Subject: [PATCH 27/28] docs: docstrings for site item and endpoint (#1495) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/site_item.py | 66 +++++ .../server/endpoint/sites_endpoint.py | 265 ++++++++++++++++++ 2 files changed, 331 insertions(+) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 2d9f014a..e4e146f9 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -21,6 +21,72 @@ class SiteItem: + """ + The SiteItem class contains the members or attributes for the site resources + on Tableau Server or Tableau Cloud. The SiteItem class defines the + information you can request or query from Tableau Server or Tableau Cloud. + The class members correspond to the attributes of a server request or + response payload. + + Attributes + ---------- + name: str + The name of the site. The name of the default site is "". + + content_url: str + The path to the site. + + admin_mode: str + (Optional) For Tableau Server only. Specify ContentAndUsers to allow + site administrators to use the server interface and tabcmd commands to + add and remove users. (Specifying this option does not give site + administrators permissions to manage users using the REST API.) Specify + ContentOnly to prevent site administrators from adding or removing + users. (Server administrators can always add or remove users.) + + user_quota: int + (Optional) Specifies the total number of users for the site. The number + can't exceed the number of licenses activated for the site; and if + tiered capacity attributes are set, then user_quota will equal the sum + of the tiered capacity values, and attempting to set user_quota will + cause an error. + + tier_explorer_capacity: int + tier_creator_capacity: int + tier_viewer_capacity: int + (Optional) The maximum number of licenses for users with the Creator, + Explorer, or Viewer role, respectively, allowed on a site. + + storage_quota: int + (Optional) Specifies the maximum amount of space for the new site, in + megabytes. If you set a quota and the site exceeds it, publishers will + be prevented from uploading new content until the site is under the + limit again. + + disable_subscriptions: bool + (Optional) Specify true to prevent users from being able to subscribe + to workbooks on the specified site. The default is False. + + subscribe_others_enabled: bool + (Optional) Specify false to prevent server administrators, site + administrators, and project or content owners from being able to + subscribe other users to workbooks on the specified site. The default + is True. + + revision_history_enabled: bool + (Optional) Specify true to enable revision history for content resources + (workbooks and datasources). The default is False. + + revision_limit: int + (Optional) Specifies the number of revisions of a content source + (workbook or data source) to allow. On Tableau Server, the default is + 25. + + state: str + Shows the current state of the site (Active or Suspended). + + """ + _user_quota: Optional[int] = None _tier_creator_capacity: Optional[int] = None _tier_explorer_capacity: Optional[int] = None diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 0f3d2590..55d2a5ad 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -15,6 +15,16 @@ class Sites(Endpoint): + """ + Using the site methods of the Tableau Server REST API you can: + + List sites on a server or get details of a specific site + Create, update, or delete a site + List views in a site + Encrypt, decrypt, or reencrypt extracts on a site + + """ + @property def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites" @@ -22,6 +32,25 @@ def baseurl(self) -> str: # Gets all sites @api(version="2.0") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[SiteItem], PaginationItem]: + """ + Query all sites on the server. This method requires server admin + permissions. This endpoint is paginated, meaning that the server will + only return a subset of the data at a time. The response will contain + information about the total number of sites and the number of sites + returned in the current response. Use the PaginationItem object to + request more data. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_sites + + Parameters + ---------- + req_options : RequestOptions, optional + Filtering options for the request. + + Returns + ------- + tuple[list[SiteItem], PaginationItem] + """ logger.info("Querying all sites on site") logger.info("Requires Server Admin permissions") url = self.baseurl @@ -33,6 +62,33 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Site # Gets 1 site by id @api(version="2.0") def get_by_id(self, site_id: str) -> SiteItem: + """ + Query a single site on the server. You can only retrieve the site that + you are currently authenticated for. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site + + Parameters + ---------- + site_id : str + The site ID. + + Returns + ------- + SiteItem + + Raises + ------ + ValueError + If the site ID is not defined. + + ValueError + If the site ID does not match the site for which you are currently authenticated. + + Examples + -------- + >>> site = server.sites.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -48,6 +104,31 @@ def get_by_id(self, site_id: str) -> SiteItem: # Gets 1 site by name @api(version="2.0") def get_by_name(self, site_name: str) -> SiteItem: + """ + Query a single site on the server. You can only retrieve the site that + you are currently authenticated for. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site + + Parameters + ---------- + site_name : str + The site name. + + Returns + ------- + SiteItem + + Raises + ------ + ValueError + If the site name is not defined. + + Examples + -------- + >>> site = server.sites.get_by_name('Tableau') + + """ if not site_name: error = "Site Name undefined." raise ValueError(error) @@ -61,6 +142,31 @@ def get_by_name(self, site_name: str) -> SiteItem: # Gets 1 site by content url @api(version="2.0") def get_by_content_url(self, content_url: str) -> SiteItem: + """ + Query a single site on the server. You can only retrieve the site that + you are currently authenticated for. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site + + Parameters + ---------- + content_url : str + The content URL. + + Returns + ------- + SiteItem + + Raises + ------ + ValueError + If the site name is not defined. + + Examples + -------- + >>> site = server.sites.get_by_name('Tableau') + + """ if content_url is None: error = "Content URL undefined." raise ValueError(error) @@ -77,6 +183,42 @@ def get_by_content_url(self, content_url: str) -> SiteItem: # Update site @api(version="2.0") def update(self, site_item: SiteItem) -> SiteItem: + """ + Modifies the settings for site. + + The site item object must include the site ID and overrides all other settings. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#update_site + + Parameters + ---------- + site_item : SiteItem + The site item that you want to update. The settings specified in the + site item override the current site settings. + + Returns + ------- + SiteItem + The site item object that was updated. + + Raises + ------ + MissingRequiredFieldError + If the site item is missing an ID. + + ValueError + If the site ID does not match the site for which you are currently authenticated. + + ValueError + If the site admin mode is set to ContentOnly and a user quota is also set. + + Examples + -------- + >>> ... + >>> site_item.name = 'New Name' + >>> updated_site = server.sites.update(site_item) + + """ if not site_item.id: error = "Site item missing ID." raise MissingRequiredFieldError(error) @@ -100,6 +242,29 @@ def update(self, site_item: SiteItem) -> SiteItem: # Delete 1 site object @api(version="2.0") def delete(self, site_id: str) -> None: + """ + Deletes the specified site from the server. You can only delete the site + if you are a Server Admin. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_site + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + ValueError + If the site ID does not match the site for which you are currently authenticated. + + Examples + -------- + >>> server.sites.delete('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -114,6 +279,47 @@ def delete(self, site_id: str) -> None: # Create new site @api(version="2.0") def create(self, site_item: SiteItem) -> SiteItem: + """ + Creates a new site on the server for the specified site item object. + + Tableau Server only. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_site + + Parameters + ---------- + site_item : SiteItem + The settings for the site that you want to create. You need to + create an instance of SiteItem and pass it to the create method. + + Returns + ------- + SiteItem + The site item object that was created. + + Raises + ------ + ValueError + If the site admin mode is set to ContentOnly and a user quota is also set. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create an instance of server + >>> server = TSC.Server('https://MY-SERVER') + + >>> # create shortcut for admin mode + >>> content_users=TSC.SiteItem.AdminMode.ContentAndUsers + + >>> # create a new SiteItem + >>> new_site = TSC.SiteItem(name='Tableau', content_url='tableau', admin_mode=content_users, user_quota=15, storage_quota=1000, disable_subscriptions=True) + + >>> # call the sites create method with the SiteItem + >>> new_site = server.sites.create(new_site) + + + """ if site_item.admin_mode: if site_item.admin_mode == SiteItem.AdminMode.ContentOnly and site_item.user_quota: error = "You cannot set admin_mode to ContentOnly and also set a user quota" @@ -128,6 +334,25 @@ def create(self, site_item: SiteItem) -> SiteItem: @api(version="3.5") def encrypt_extracts(self, site_id: str) -> None: + """ + Encrypts all extracts on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#encrypt_extracts + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + Examples + -------- + >>> server.sites.encrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -137,6 +362,25 @@ def encrypt_extracts(self, site_id: str) -> None: @api(version="3.5") def decrypt_extracts(self, site_id: str) -> None: + """ + Decrypts all extracts on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#decrypt_extracts + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + Examples + -------- + >>> server.sites.decrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -146,6 +390,27 @@ def decrypt_extracts(self, site_id: str) -> None: @api(version="3.5") def re_encrypt_extracts(self, site_id: str) -> None: + """ + Reencrypt all extracts on a site with new encryption keys. If no site is + specified, extracts on the default site will be reencrypted. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#reencrypt_extracts + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + Examples + -------- + >>> server.sites.re_encrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + + """ if not site_id: error = "Site ID undefined." raise ValueError(error) From c3ea910efbe1cfd21c5d773c1dd4faba909269ca Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 23 Oct 2024 11:51:08 -0700 Subject: [PATCH 28/28] Bring development and master into sync (#1509)