From fc9568de2e9b5e566120c2f20fe2f678df6a782e Mon Sep 17 00:00:00 2001 From: Tim Payne <47423639+ma7tcsp@users.noreply.github.com> Date: Wed, 12 Apr 2023 20:26:42 +0100 Subject: [PATCH 01/23] Update user_item.py (#1217) TableauIDWithMFA added to the user_item model to allow creating users on Tableau Cloud with MFA enabled and to keep in sync with REST API. Fixing Issue #1216 --- tableauserverclient/models/user_item.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 5e3d18fa6..a12f4b557 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -45,6 +45,7 @@ class Roles: class Auth: OpenID = "OpenID" SAML = "SAML" + TableauIDWithMFA = "TableauIDWithMFA" ServerDefault = "ServerDefault" def __init__( From 3cc28be8e18af0f36dfd390c7c3a306e5d90f6a0 Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 18 Apr 2023 19:59:35 -0700 Subject: [PATCH 02/23] run long requests on second thread (#1212) * run long requests on second thread * improve chunked upload requests * begin extracting constants for user editing * centrally configured logger --- .gitignore | 1 + samples/add_default_permission.py | 10 +- samples/create_group.py | 10 +- samples/create_project.py | 10 +- samples/create_schedules.py | 10 +- samples/explore_datasource.py | 10 +- samples/explore_site.py | 10 +- samples/explore_webhooks.py | 10 +- samples/explore_workbook.py | 10 +- samples/export.py | 10 +- samples/extracts.py | 10 +- samples/filter_sort_groups.py | 10 +- samples/filter_sort_projects.py | 10 +- samples/initialize_server.py | 14 +-- samples/kill_all_jobs.py | 10 +- samples/list.py | 10 +- samples/login.py | 21 +++- samples/metadata_query.py | 10 +- samples/move_workbook_projects.py | 14 +-- samples/move_workbook_sites.py | 14 +-- samples/pagination_sample.py | 10 +- samples/publish_datasource.py | 40 +++++-- samples/publish_workbook.py | 12 +- samples/query_permissions.py | 10 +- samples/refresh.py | 10 +- samples/refresh_tasks.py | 10 +- samples/set_refresh_schedule.py | 10 +- samples/smoke_test.py | 10 +- samples/update_connection.py | 10 +- samples/update_datasource_data.py | 10 +- tableauserverclient/config.py | 13 +++ tableauserverclient/datetime_helpers.py | 4 + tableauserverclient/helpers/logging.py | 6 + tableauserverclient/models/connection_item.py | 2 +- tableauserverclient/models/favorites_item.py | 3 +- tableauserverclient/models/fileupload_item.py | 4 +- .../models/permissions_item.py | 2 +- .../models/server_info_item.py | 2 +- tableauserverclient/server/__init__.py | 4 +- .../server/endpoint/__init__.py | 6 +- .../server/endpoint/auth_endpoint.py | 2 +- .../server/endpoint/custom_views_endpoint.py | 2 +- .../data_acceleration_report_endpoint.py | 2 +- .../server/endpoint/data_alert_endpoint.py | 2 +- .../server/endpoint/databases_endpoint.py | 2 +- .../server/endpoint/datasources_endpoint.py | 36 +++--- .../endpoint/default_permissions_endpoint.py | 2 +- .../server/endpoint/dqw_endpoint.py | 2 +- .../server/endpoint/endpoint.py | 109 ++++++++++++++++-- .../server/endpoint/exceptions.py | 6 +- .../server/endpoint/favorites_endpoint.py | 2 +- .../server/endpoint/fileuploads_endpoint.py | 21 ++-- .../server/endpoint/flow_runs_endpoint.py | 2 +- .../server/endpoint/flows_endpoint.py | 10 +- .../server/endpoint/groups_endpoint.py | 2 +- .../server/endpoint/jobs_endpoint.py | 2 +- .../server/endpoint/metadata_endpoint.py | 2 +- .../server/endpoint/metrics_endpoint.py | 2 +- .../server/endpoint/permissions_endpoint.py | 2 +- .../server/endpoint/projects_endpoint.py | 2 +- .../server/endpoint/resource_tagger.py | 6 +- .../server/endpoint/schedules_endpoint.py | 3 +- .../server/endpoint/server_info_endpoint.py | 6 +- .../server/endpoint/sites_endpoint.py | 2 +- .../server/endpoint/subscriptions_endpoint.py | 2 +- .../server/endpoint/tables_endpoint.py | 2 +- .../server/endpoint/tasks_endpoint.py | 2 +- .../server/endpoint/users_endpoint.py | 2 +- .../server/endpoint/views_endpoint.py | 2 +- .../server/endpoint/webhooks_endpoint.py | 2 +- .../server/endpoint/workbooks_endpoint.py | 3 +- tableauserverclient/server/exceptions.py | 9 +- tableauserverclient/server/request_options.py | 2 +- tableauserverclient/server/server.py | 25 ++-- test/test_auth.py | 6 +- test/test_datasource.py | 6 +- test/test_endpoint.py | 25 +++- test/test_fileuploads.py | 4 +- test/test_request_option.py | 6 +- test/test_webhook.py | 3 +- 80 files changed, 395 insertions(+), 327 deletions(-) create mode 100644 tableauserverclient/config.py create mode 100644 tableauserverclient/helpers/logging.py diff --git a/.gitignore b/.gitignore index d8caf99a9..f0226c065 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,7 @@ celerybeat-schedule # dotenv .env +env.py # virtualenv venv/ diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py index 8a87c1fd6..5a450e8ab 100644 --- a/samples/add_default_permission.py +++ b/samples/add_default_permission.py @@ -18,14 +18,10 @@ def main(): parser = argparse.ArgumentParser(description="Add workbook default permissions for a given project.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/create_group.py b/samples/create_group.py index 2229f7f26..f4c6a9ca9 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -20,14 +20,10 @@ def main(): parser = argparse.ArgumentParser(description="Creates a sample user group.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/create_project.py b/samples/create_project.py index 8b2ec3354..611dbe366 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -28,14 +28,10 @@ def create_project(server, project_item, samples=False): def main(): parser = argparse.ArgumentParser(description="Create new projects.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/create_schedules.py b/samples/create_schedules.py index f193352de..dee088571 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -17,14 +17,10 @@ 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", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index aafbe167c..fb45cb45e 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -18,14 +18,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore datasource functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/explore_site.py b/samples/explore_site.py index a181abfec..a2274f1a7 100644 --- a/samples/explore_site.py +++ b/samples/explore_site.py @@ -14,14 +14,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore site updates by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index 47e59ac06..77802b1db 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -19,14 +19,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore webhook functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index f242ace70..c61b9b637 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -19,14 +19,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore workbook functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/export.py b/samples/export.py index 4c26770b9..f2783fa6e 100644 --- a/samples/export.py +++ b/samples/export.py @@ -14,14 +14,10 @@ def main(): parser = argparse.ArgumentParser(description="Export a view as an image, PDF, or CSV") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/extracts.py b/samples/extracts.py index c77da89d0..9bd87a473 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -19,14 +19,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore extract functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", help="site name") - parser.add_argument( - "--token-name", "-tn", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-tv", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-tn", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-tv", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index 984d8d344..042af32e2 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -26,14 +26,10 @@ def create_example_group(group_name="Example Group", server=None): def main(): parser = argparse.ArgumentParser(description="Filter and sort groups.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index 608f472ba..7aa62a5c1 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -29,14 +29,10 @@ def create_example_project( def main(): parser = argparse.ArgumentParser(description="Filter and sort projects.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/initialize_server.py b/samples/initialize_server.py index e7ed0139f..cb3d9e1d0 100644 --- a/samples/initialize_server.py +++ b/samples/initialize_server.py @@ -13,14 +13,10 @@ def main(): parser = argparse.ArgumentParser(description="Initialize a server with content.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", @@ -29,8 +25,8 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--datasources-folder", "-df", required=True, help="folder containing datasources") - parser.add_argument("--workbooks-folder", "-wf", required=True, help="folder containing workbooks") + parser.add_argument("--datasources-folder", "-df", help="folder containing datasources") + parser.add_argument("--workbooks-folder", "-wf", help="folder containing workbooks") parser.add_argument("--project", required=False, default="Default", help="project to use") args = parser.parse_args() diff --git a/samples/kill_all_jobs.py b/samples/kill_all_jobs.py index 1a833f938..bfebb49b8 100644 --- a/samples/kill_all_jobs.py +++ b/samples/kill_all_jobs.py @@ -13,14 +13,10 @@ def main(): parser = argparse.ArgumentParser(description="Cancel all of the running background jobs.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/list.py b/samples/list.py index b5cdb38a5..8d72fb620 100644 --- a/samples/list.py +++ b/samples/list.py @@ -15,14 +15,10 @@ def main(): parser = argparse.ArgumentParser(description="List out the names and LUIDs for different resource types.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-n", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-n", 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", diff --git a/samples/login.py b/samples/login.py index f3e9d77dc..6a3e9e8b3 100644 --- a/samples/login.py +++ b/samples/login.py @@ -9,6 +9,7 @@ import logging import tableauserverclient as TSC +import env # If a sample has additional arguments, then it should copy this code and insert them after the call to @@ -18,10 +19,15 @@ def set_up_and_log_in(): parser = argparse.ArgumentParser(description="Logs in to the server.") sample_define_common_options(parser) 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) + if not args.server: + args.server = env.server + if not args.site: + args.site = env.site + if not args.token_name: + args.token_name = env.token_name + if not args.token_value: + args.token_value = env.token_value + args.logging_level = "debug" server = sample_connect_to_server(args) print(server.server_info.get()) @@ -30,9 +36,9 @@ def set_up_and_log_in(): def sample_define_common_options(parser): # Common options; please keep these in sync across all samples by copying or calling this method directly - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-t", help="site name") - auth = parser.add_mutually_exclusive_group(required=True) + auth = parser.add_mutually_exclusive_group(required=False) auth.add_argument("--token-name", "-tn", help="name of the personal access token used to sign into the server") auth.add_argument("--username", "-u", help="username to sign into the server") @@ -73,6 +79,9 @@ 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") return server diff --git a/samples/metadata_query.py b/samples/metadata_query.py index 26f8f94fa..7524453c2 100644 --- a/samples/metadata_query.py +++ b/samples/metadata_query.py @@ -14,14 +14,10 @@ def main(): parser = argparse.ArgumentParser(description="Use the metadata API to get information on a published data source.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-n", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-n", 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", diff --git a/samples/move_workbook_projects.py b/samples/move_workbook_projects.py index be49ec23b..392dc0ff8 100644 --- a/samples/move_workbook_projects.py +++ b/samples/move_workbook_projects.py @@ -17,14 +17,10 @@ def main(): parser = argparse.ArgumentParser(description="Move one workbook from the default project to another.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", @@ -33,8 +29,8 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--workbook-name", "-w", required=True, help="name of workbook to move") - parser.add_argument("--destination-project", "-d", required=True, help="name of project to move workbook into") + parser.add_argument("--workbook-name", "-w", help="name of workbook to move") + parser.add_argument("--destination-project", "-d", help="name of project to move workbook into") args = parser.parse_args() diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index 3feb62be2..47af1f2f9 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -22,14 +22,10 @@ def main(): "the default project of another site." ) # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", @@ -38,8 +34,8 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--workbook-name", "-w", required=True, help="name of workbook to move") - parser.add_argument("--destination-site", "-d", required=True, help="name of site to move workbook into") + parser.add_argument("--workbook-name", "-w", help="name of workbook to move") + parser.add_argument("--destination-site", "-d", help="name of site to move workbook into") args = parser.parse_args() diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index b55fef320..a7ae6dc89 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -20,14 +20,10 @@ def main(): parser = argparse.ArgumentParser(description="Demonstrate pagination on the list of workbooks on the server.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-n", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-n", 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", diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index 8d9e59ea2..5ac768674 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -23,18 +23,17 @@ import tableauserverclient as TSC +import env +import tableauserverclient.datetime_helpers + def main(): parser = argparse.ArgumentParser(description="Publish a datasource to server.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", @@ -43,7 +42,7 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--file", "-f", required=True, help="filepath to the datasource to publish") + parser.add_argument("--file", "-f", help="filepath to the datasource to publish") parser.add_argument("--project", help="Project within which to publish the datasource") parser.add_argument("--async", "-a", help="Publishing asynchronously", dest="async_", action="store_true") parser.add_argument("--conn-username", help="connection username") @@ -52,14 +51,27 @@ def main(): parser.add_argument("--conn-oauth", help="connection is configured to use oAuth", action="store_true") args = parser.parse_args() + if not args.server: + args.server = env.server + if not args.site: + args.site = env.site + if not args.token_name: + args.token_name = env.token_name + if not args.token_value: + args.token_value = env.token_value + args.logging = "debug" + args.file = "C:/dev/tab-samples/5M.tdsx" + args.async_ = True # Ensure that both the connection username and password are provided, or none at all if (args.conn_username and not args.conn_password) or (not args.conn_username and args.conn_password): parser.error("Both the connection username and password must be provided") # Set logging level based on user input, or error by default - logging_level = getattr(logging, args.logging_level.upper()) - logging.basicConfig(level=logging_level) + + _logger = logging.getLogger(__name__) + _logger.setLevel(logging.DEBUG) + _logger.addHandler(logging.StreamHandler()) # Sign in to server tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) @@ -94,6 +106,7 @@ def main(): # Publish datasource if args.async_: + print("Publish as a job") # Async publishing, returns a job_item new_job = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds, as_job=True @@ -104,7 +117,12 @@ def main(): new_datasource = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds ) - print("Datasource published. Datasource ID: {0}".format(new_datasource.id)) + print( + "{0}Datasource published. Datasource ID: {1}".format( + new_datasource.id, tableauserverclient.datetime_helpers.timestamp() + ) + ) + print("\t\tClosing connection") if __name__ == "__main__": diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index f0edc380c..8a9f45279 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -24,14 +24,10 @@ def main(): parser = argparse.ArgumentParser(description="Publish a workbook to server.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", @@ -40,7 +36,7 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--file", "-f", required=True, help="local filepath of the workbook to publish") + parser.add_argument("--file", "-f", help="local filepath of the workbook to publish") parser.add_argument("--as-job", "-a", help="Publishing asynchronously", action="store_true") parser.add_argument("--skip-connection-check", "-c", help="Skip live connection check", action="store_true") diff --git a/samples/query_permissions.py b/samples/query_permissions.py index 7106da934..4e509cd97 100644 --- a/samples/query_permissions.py +++ b/samples/query_permissions.py @@ -15,14 +15,10 @@ def main(): parser = argparse.ArgumentParser(description="Query permissions of a given resource.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/refresh.py b/samples/refresh.py index f90441224..d3e49ed24 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -13,14 +13,10 @@ def main(): parser = argparse.ArgumentParser(description="Trigger a refresh task on a workbook or datasource.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index 2bfc85621..03daedf16 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -30,14 +30,10 @@ def handle_info(server, args): def main(): parser = argparse.ArgumentParser(description="Get all of the refresh tasks available on a server") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/set_refresh_schedule.py b/samples/set_refresh_schedule.py index 9b3dbc236..56fd12e62 100644 --- a/samples/set_refresh_schedule.py +++ b/samples/set_refresh_schedule.py @@ -15,14 +15,10 @@ def usage(args): parser = argparse.ArgumentParser(description="Set refresh schedule for a workbook or datasource.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/smoke_test.py b/samples/smoke_test.py index f2dad1048..b23eacdb8 100644 --- a/samples/smoke_test.py +++ b/samples/smoke_test.py @@ -1,8 +1,16 @@ # This sample verifies that tableau server client is installed # and you can run it. It also shows the version of the client. +import logging import tableauserverclient as TSC + +logger = logging.getLogger("Sample") +logger.setLevel(logging.DEBUG) +logger.addHandler(logging.StreamHandler()) + + server = TSC.Server("Fake-Server-Url", use_server_version=False) print("Client details:") -print(TSC.server.endpoint.Endpoint._make_common_headers("fake-token", "any-content")) +logger.info(server.server_address) +logger.debug(TSC.server.endpoint.Endpoint.set_user_agent({})) diff --git a/samples/update_connection.py b/samples/update_connection.py index e27b4477f..4af6592bc 100644 --- a/samples/update_connection.py +++ b/samples/update_connection.py @@ -13,14 +13,10 @@ def main(): parser = argparse.ArgumentParser(description="Update a connection on a datasource or workbook to embed credentials") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/samples/update_datasource_data.py b/samples/update_datasource_data.py index 41f42ee74..f6bc92022 100644 --- a/samples/update_datasource_data.py +++ b/samples/update_datasource_data.py @@ -25,14 +25,10 @@ def main(): description="Delete the `Europe` region from a published `World Indicators` datasource." ) # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + 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", diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py new file mode 100644 index 000000000..67a77f479 --- /dev/null +++ b/tableauserverclient/config.py @@ -0,0 +1,13 @@ +# TODO: check for env variables, else set default values + +ALLOWED_FILE_EXTENSIONS = ["tds", "tdsx", "tde", "hyper", "parquet"] + +BYTES_PER_MB = 1024 * 1024 + +# For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks +CHUNK_SIZE_MB = 5 * 10 # 5MB felt too slow, upped it to 50 + +DELAY_SLEEP_SECONDS = 10 + +# The maximum size of a file that can be published in a single request is 64MB +FILESIZE_LIMIT_MB = 64 diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index 0d968428d..00f62faf8 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -5,6 +5,10 @@ HOUR = datetime.timedelta(hours=1) +def timestamp(): + return datetime.datetime.now().strftime("%H:%M:%S") + + # This class is a concrete implementation of the abstract base class tzinfo # docs: https://docs.python.org/2.3/lib/datetime-tzinfo.html class UTC(datetime.tzinfo): diff --git a/tableauserverclient/helpers/logging.py b/tableauserverclient/helpers/logging.py new file mode 100644 index 000000000..414d85786 --- /dev/null +++ b/tableauserverclient/helpers/logging.py @@ -0,0 +1,6 @@ +import logging + +# TODO change: this defaults to logging *everything* to stdout +logger = logging.getLogger("TSC") +logger.setLevel(logging.DEBUG) +logger.addHandler(logging.StreamHandler()) diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 4ed06b831..29ffd2700 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -5,6 +5,7 @@ from .connection_credentials import ConnectionCredentials from .property_decorators import property_is_boolean +from tableauserverclient.helpers.logging import logger class ConnectionItem(object): @@ -46,7 +47,6 @@ def query_tagging(self) -> Optional[bool]: def query_tagging(self, value: Optional[bool]): # if connection type = hyper, Snowflake, or Teradata, we can't change this value: it is always true if self._connection_type in ["hyper", "snowflake", "teradata"]: - logger = logging.getLogger("tableauserverclient.models.connection_item") logger.debug( "Cannot update value: Query tagging is always enabled for {} connections".format(self._connection_type) ) diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index afa769fd9..dd9dcfaed 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -8,8 +8,7 @@ from .view_item import ViewItem from .workbook_item import WorkbookItem -logger = logging.getLogger("tableau.models.favorites_item") - +from tableauserverclient.helpers.logging import logger from typing import Dict, List, Union FavoriteType = Dict[ diff --git a/tableauserverclient/models/fileupload_item.py b/tableauserverclient/models/fileupload_item.py index 7848b94cf..e9bdd25b2 100644 --- a/tableauserverclient/models/fileupload_item.py +++ b/tableauserverclient/models/fileupload_item.py @@ -11,8 +11,8 @@ def upload_session_id(self): return self._upload_session_id @property - def file_size(self): - return self._file_size + def file_size(self) -> int: + return int(self._file_size) @classmethod def from_response(cls, resp, ns): diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 3bdc63092..1602b077f 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -9,7 +9,7 @@ from .reference_item import ResourceReference from .user_item import UserItem -logger = logging.getLogger("tableau.models.permissions_item") +from tableauserverclient.helpers.logging import logger class Permission: diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index 5f9395880..b180665dd 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -3,6 +3,7 @@ import xml from defusedxml.ElementTree import fromstring +from tableauserverclient.helpers.logging import logger class ServerInfoItem(object): @@ -36,7 +37,6 @@ def rest_api_version(self): @classmethod def from_response(cls, resp, ns): - logger = logging.getLogger("TSC.ServerInfo") try: parsed_response = fromstring(resp) except xml.etree.ElementTree.ParseError as error: diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index bcea2604e..5abe19446 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -10,9 +10,7 @@ from .filter import Filter from .sort import Sort -from ..models import * from .endpoint import * from .server import Server from .pager import Pager -from .exceptions import NotSignedInError -from ..helpers import * +from .endpoint.exceptions import NotSignedInError diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index e8e1bc0f9..c018d8334 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -5,11 +5,7 @@ from .databases_endpoint import Databases from .datasources_endpoint import Datasources from .endpoint import Endpoint, QuerysetEndpoint -from .exceptions import ( - ServerResponseError, - MissingRequiredFieldError, - ServerInfoEndpointNotFoundError, -) +from .exceptions import ServerResponseError, MissingRequiredFieldError from .favorites_endpoint import Favorites from .fileuploads_endpoint import Fileuploads from .flow_runs_endpoint import FlowRuns diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 68d75eaa8..6f1ddc35e 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -6,7 +6,7 @@ from .exceptions import ServerResponseError from ..request_factory import RequestFactory -logger = logging.getLogger("tableau.endpoint.auth") +from tableauserverclient.helpers.logging import logger class Auth(Endpoint): diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index 778cafecc..119580609 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.models import CustomViewItem, PaginationItem from tableauserverclient.server import RequestFactory, RequestOptions, ImageRequestOptions -logger = logging.getLogger("tableau.endpoint.custom_views") +from tableauserverclient.helpers.logging import logger """ Get a list of custom views on a site diff --git a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py index 28e5495c5..256a6e766 100644 --- a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py +++ b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py @@ -5,7 +5,7 @@ from .permissions_endpoint import _PermissionsEndpoint from tableauserverclient.models import DataAccelerationReportItem -logger = logging.getLogger("tableau.endpoint.data_acceleration_report") +from tableauserverclient.helpers.logging import logger class DataAccelerationReport(Endpoint): diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py index 5af4e0464..fd02d2e4a 100644 --- a/tableauserverclient/server/endpoint/data_alert_endpoint.py +++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py @@ -5,7 +5,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import DataAlertItem, PaginationItem, UserItem -logger = logging.getLogger("tableau.endpoint.dataAlerts") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, TYPE_CHECKING, Tuple, Union diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 2522ef53e..125996277 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -8,7 +8,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import DatabaseItem, TableItem, PaginationItem, Resource -logger = logging.getLogger("tableau.endpoint.databases") +from tableauserverclient.helpers.logging import logger class Databases(Endpoint): diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 0c5b8ba61..c60f8f919 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -1,7 +1,6 @@ import cgi import copy import json -import logging import io import os @@ -20,13 +19,14 @@ from .permissions_endpoint import _PermissionsEndpoint from .resource_tagger import _ResourceTagger -from tableauserverclient.server import RequestFactory, RequestOptions +from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, CHUNK_SIZE_MB from tableauserverclient.filesys_helpers import ( - to_filename, make_download_path, get_file_type, get_file_object_size, + to_filename, ) +from tableauserverclient.helpers.logging import logger from tableauserverclient.models import ( ConnectionCredentials, ConnectionItem, @@ -35,6 +35,7 @@ RevisionItem, PaginationItem, ) +from tableauserverclient.server import RequestFactory, RequestOptions io_types = (io.BytesIO, io.BufferedReader) io_types_r = (io.BytesIO, io.BufferedReader) @@ -44,13 +45,6 @@ FileObject = Union[io.BufferedReader, io.BytesIO] PathOrFile = Union[FilePath, FileObject] -# The maximum size of a file that can be published in a single request is 64MB -FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB - -ALLOWED_FILE_EXTENSIONS = ["tds", "tdsx", "tde", "hyper", "parquet"] - -logger = logging.getLogger("tableau.endpoint.datasources") - FilePath = Union[str, os.PathLike] FileObjectR = Union[io.BufferedReader, io.BytesIO] FileObjectW = Union[io.BufferedWriter, io.BytesIO] @@ -162,12 +156,20 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: # Update datasource connections @api(version="2.3") - def update_connection(self, datasource_item: DatasourceItem, connection_item: ConnectionItem) -> ConnectionItem: + 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) 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] + connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) + if not connections: + return None + + if len(connections) > 1: + logger.debug("Multiple connections returned ({0})".format(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) @@ -220,7 +222,7 @@ def publish( 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)) # 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] @@ -261,8 +263,12 @@ def publish( url += "&{0}=true".format("asJob") # 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 (datasource over 64MB)".format(filename)) + if file_size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: + logger.info( + "Publishing {} to server with chunking method (datasource over {}MB, chunk size {}MB)".format( + filename, FILESIZE_LIMIT_MB, CHUNK_SIZE_MB + ) + ) upload_session_id = self.parent_srv.fileuploads.upload(file) url = "{0}&uploadSessionId={1}".format(url, upload_session_id) xml_request, content_type = RequestFactory.Datasource.publish_req_chunked( diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index b0d16efaf..19112d713 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -10,7 +10,7 @@ from ..server import Server from ..request_options import RequestOptions -logger = logging.getLogger(__name__) +from tableauserverclient.helpers.logging import logger # these are the only two items that can hold default permissions for another type BaseItem = Union[DatabaseItem, ProjectItem] diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index 96cb7c5f9..5296523ee 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -5,7 +5,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import DQWItem -logger = logging.getLogger(__name__) +from tableauserverclient.helpers.logging import logger class _DataQualityWarningEndpoint(Endpoint): diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 9c933c9dd..c11a3fb27 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,24 +1,31 @@ +from threading import Thread +from time import sleep +from tableauserverclient import datetime_helpers as datetime + import requests -import logging from packaging.version import Version from functools import wraps from xml.etree.ElementTree import ParseError -from typing import Any, Callable, Dict, Optional, TYPE_CHECKING +from typing import Any, Callable, Dict, Optional, TYPE_CHECKING, Union from .exceptions import ( ServerResponseError, InternalServerError, NonXMLResponseError, - EndpointUnavailableError, + NotSignedInError, ) +from ..exceptions import EndpointUnavailableError + from tableauserverclient.server.query import QuerySet from tableauserverclient import helpers, get_versions +from tableauserverclient.helpers.logging import logger +from tableauserverclient.config import DELAY_SLEEP_SECONDS + if TYPE_CHECKING: from ..server import Server from requests import Response -logger = logging.getLogger("tableau.endpoint") Success_codes = [200, 201, 202, 204] @@ -34,6 +41,8 @@ class Endpoint(object): def __init__(self, parent_srv: "Server"): self.parent_srv = parent_srv + async_response = None + @staticmethod def set_parameters(http_options, auth_token, content, content_type, parameters) -> Dict[str, Any]: parameters = parameters or {} @@ -53,6 +62,8 @@ def set_parameters(http_options, auth_token, content, content_type, parameters) @staticmethod def set_user_agent(parameters): + if "headers" not in parameters: + parameters["headers"] = {} if USER_AGENT_HEADER not in parameters["headers"]: if USER_AGENT_HEADER in parameters: parameters["headers"][USER_AGENT_HEADER] = parameters[USER_AGENT_HEADER] @@ -65,6 +76,59 @@ def set_user_agent(parameters): # return explicitly for testing only return parameters + def _blocking_request(self, method, url, parameters={}) -> Optional["Response"]: + self.async_response = None + response = None + logger.debug("[{}] Begin blocking request to {}".format(datetime.timestamp(), url)) + try: + response = method(url, **parameters) + self.async_response = response + logger.debug("[{}] Call finished".format(datetime.timestamp())) + except Exception as e: + logger.debug("Error making request to server: {}".format(e)) + self.async_response = e + finally: + if response and not self.async_response: + logger.debug("Request response not saved") + return None + logger.debug("[{}] Request complete".format(datetime.timestamp())) + return self.async_response + + def send_request_while_show_progress_threaded( + self, method, url, parameters={}, request_timeout=0 + ) -> Optional["Response"]: + try: + request_thread = Thread(target=self._blocking_request, args=(method, url, parameters)) + request_thread.async_response = -1 # type:ignore # this is an invented attribute for thread comms + request_thread.start() + except Exception as e: + logger.debug("Error starting server request on separate thread: {}".format(e)) + return None + seconds = 0 + minutes = 0 + sleep(1) + if self.async_response != -1: + # a quick return for any immediate responses + return self.async_response + while self.async_response == -1 and (request_timeout == 0 or seconds < request_timeout): + self.log_wait_time_then_sleep(minutes, seconds, url) + seconds = seconds + DELAY_SLEEP_SECONDS + if seconds >= 60: + seconds = 0 + minutes = minutes + 1 + return self.async_response + + def log_wait_time_then_sleep(self, minutes, seconds, url): + logger.debug("{} Waiting....".format(datetime.timestamp())) + if seconds >= 60: # detailed log message ~every minute + if minutes % 5 == 0: + logger.info( + "[{}] Waiting ({} minutes so far) for request to {}".format(datetime.timestamp(), minutes, url) + ) + else: + logger.debug("[{}] Waiting for request to {}".format(datetime.timestamp(), url)) + sleep(DELAY_SLEEP_SECONDS) + def _make_request( self, method: Callable[..., "Response"], @@ -80,36 +144,59 @@ def _make_request( logger.debug("request method {}, url: {}".format(method.__name__, url)) if content: - redacted = helpers.strings.redact_xml(content[:1000]) + redacted = helpers.strings.redact_xml(content[:200]) + # this needs to be under a trace or something, it's a LOT # logger.debug("request content: {}".format(redacted)) - server_response = method(url, **parameters) + # a request can, for stuff like publishing, spin for ages waiting for a response. + # we need some user-facing activity so they know it's not dead. + request_timeout = self.parent_srv.http_options.get("timeout") or 0 + server_response: Optional["Response"] = self.send_request_while_show_progress_threaded( + method, url, parameters, request_timeout + ) + logger.debug("[{}] Async request returned: received {}".format(datetime.timestamp(), server_response)) + # is this blocking retry really necessary? I guess if it was just the threading messing it up? + if server_response is None: + logger.debug(server_response) + logger.debug("[{}] Async request failed: retrying".format(datetime.timestamp())) + server_response = self._blocking_request(method, url, parameters) + if server_response is None: + logger.debug("[{}] Request failed".format(datetime.timestamp())) + raise RuntimeError self._check_status(server_response, url) loggable_response = self.log_response_safely(server_response) - # logger.debug("Server response from {0}:\n\t{1}".format(url, loggable_response)) + logger.debug("Server response from {0}".format(url)) + # logger.debug("\n\t{1}".format(loggable_response)) if content_type == "application/xml": self.parent_srv._namespace.detect(server_response.content) return server_response - def _check_status(self, server_response, url: Optional[str] = None): + def _check_status(self, server_response: "Response", url: Optional[str] = None): + logger.debug("Response status: {}".format(server_response)) + if not hasattr(server_response, "status_code"): + raise EnvironmentError("Response is not a http response?") if server_response.status_code >= 500: raise InternalServerError(server_response, url) elif server_response.status_code not in Success_codes: try: + if server_response.status_code == 401: + # TODO: catch this in server.py and attempt to sign in again, in case it's a session expiry + raise NotSignedInError(server_response.content, url) + raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace, url) except ParseError: # This will happen if we get a non-success HTTP code that doesn't return an xml error object - # e.g metadata endpoints, 503 pages, totally different servers + # e.g. metadata endpoints, 503 pages, totally different servers # we convert this to a better exception and pass through the raw response body raise NonXMLResponseError(server_response.content) except Exception: # anything else re-raise here raise - def log_response_safely(self, server_response: requests.Response) -> str: + def log_response_safely(self, server_response: "Response") -> str: # Checking the content type header prevents eager evaluation of streaming requests. content_type = server_response.headers.get("Content-Type") @@ -117,7 +204,7 @@ def log_response_safely(self, server_response: requests.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 = "Content type `{}`".format(content_type) if content_type == "application/octet-stream": loggable_response = "A stream of type {} [Truncated File Contents]".format(content_type) elif server_response.encoding and len(server_response.content) > 0: diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index d7b1d5ad2..9dfd38da6 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -47,11 +47,7 @@ class MissingRequiredFieldError(TableauError): pass -class ServerInfoEndpointNotFoundError(TableauError): - pass - - -class EndpointUnavailableError(TableauError): +class NotSignedInError(TableauError): pass diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 5105b3bf4..81bb468f8 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -10,7 +10,7 @@ from ...models import DatasourceItem, FlowItem, ProjectItem, UserItem, ViewItem, WorkbookItem from ..request_options import RequestOptions -logger = logging.getLogger("tableau.endpoint.favorites") +from tableauserverclient.helpers.logging import logger class Favorites(Endpoint): diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 9a8e9560d..a0e29e508 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -1,13 +1,10 @@ -import logging - from .endpoint import Endpoint, api -from tableauserverclient.server import RequestFactory -from tableauserverclient.models import FileuploadItem +from tableauserverclient import datetime_helpers as datetime +from tableauserverclient.helpers.logging import logger -# For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks -CHUNK_SIZE = 1024 * 1024 * 5 # 5MB - -logger = logging.getLogger("tableau.endpoint.fileuploads") +from tableauserverclient.config import BYTES_PER_MB, CHUNK_SIZE_MB +from tableauserverclient.models import FileuploadItem +from tableauserverclient.server import RequestFactory class Fileuploads(Endpoint): @@ -44,7 +41,7 @@ def _read_chunks(self, file): try: while True: - chunked_content = file_content.read(CHUNK_SIZE) + chunked_content = file_content.read(CHUNK_SIZE_MB * BYTES_PER_MB) if not chunked_content: break yield chunked_content @@ -55,8 +52,12 @@ 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())) request, content_type = RequestFactory.Fileupload.chunk_req(chunk) + logger.debug("{} created chunk request".format(datetime.timestamp())) fileupload_item = self.append(upload_id, request, content_type) - logger.info("\tPublished {0}MB".format(fileupload_item.file_size)) + logger.info( + "\t{0} Published {1}MB".format(datetime.timestamp(), (fileupload_item.file_size / BYTES_PER_MB)) + ) logger.info("File upload finished (ID: {0})".format(upload_id)) return upload_id diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 3bca93a7f..63b32e006 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.models import FlowRunItem, PaginationItem from tableauserverclient.exponential_backoff import ExponentialBackoffTimer -logger = logging.getLogger("tableau.endpoint.flowruns") +from tableauserverclient.helpers.logging import logger if TYPE_CHECKING: from ..server import Server diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 4d97110c4..ba8a152d7 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -32,13 +32,13 @@ ALLOWED_FILE_EXTENSIONS = ["tfl", "tflx"] -logger = logging.getLogger("tableau.endpoint.flows") +from tableauserverclient.helpers.logging import logger if TYPE_CHECKING: - from .. import DQWItem - from ..request_options import RequestOptions - from ...models.permissions_item import PermissionsRule - from .schedules_endpoint import AddResponse + from tableauserverclient.models import DQWItem + from tableauserverclient.models.permissions_item import PermissionsRule + from tableauserverclient.server.request_options import RequestOptions + from tableauserverclient.server.endpoint.schedules_endpoint import AddResponse FilePath = Union[str, os.PathLike] diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index ba5b6649b..ad3828568 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.models import GroupItem, UserItem, PaginationItem, JobItem from ..pager import Pager -logger = logging.getLogger("tableau.endpoint.groups") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, TYPE_CHECKING, Tuple, Union diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index dd210d990..d0b865e21 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -6,7 +6,7 @@ from ..request_options import RequestOptionsBase from tableauserverclient.exponential_backoff import ExponentialBackoffTimer -logger = logging.getLogger("tableau.endpoint.jobs") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, Tuple, Union diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index 06339fa79..39146d062 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -4,7 +4,7 @@ from .endpoint import Endpoint, api from .exceptions import GraphQLError, InvalidGraphQLQuery -logger = logging.getLogger("tableau.endpoint.metadata") +from tableauserverclient.helpers.logging import logger def is_valid_paged_query(parsed_query): diff --git a/tableauserverclient/server/endpoint/metrics_endpoint.py b/tableauserverclient/server/endpoint/metrics_endpoint.py index 8443726cd..a0e984475 100644 --- a/tableauserverclient/server/endpoint/metrics_endpoint.py +++ b/tableauserverclient/server/endpoint/metrics_endpoint.py @@ -15,7 +15,7 @@ from ...server import Server -logger = logging.getLogger("tableau.endpoint.metrics") +from tableauserverclient.helpers.logging import logger class Metrics(QuerysetEndpoint): diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index e50e32945..4433625f2 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -8,7 +8,7 @@ from typing import Callable, TYPE_CHECKING, List, Optional, Union -logger = logging.getLogger(__name__) +from tableauserverclient.helpers.logging import logger if TYPE_CHECKING: from ..server import Server diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 440940606..510f1ff3d 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -13,7 +13,7 @@ from ..server import Server from ..request_options import RequestOptions -logger = logging.getLogger("tableau.endpoint.projects") +from tableauserverclient.helpers.logging import logger class Projects(QuerysetEndpoint): diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 18c38798e..8177bd733 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,13 +1,13 @@ import copy -import logging import urllib.parse from .endpoint import Endpoint -from .exceptions import EndpointUnavailableError, ServerResponseError +from .exceptions import ServerResponseError +from ..exceptions import EndpointUnavailableError from tableauserverclient.server import RequestFactory from tableauserverclient.models import TagItem -logger = logging.getLogger("tableau.endpoint.resource_tagger") +from tableauserverclient.helpers.logging import logger class _ResourceTagger(Endpoint): diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 7cca1f5d5..cfaee3324 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -9,7 +9,8 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import PaginationItem, ScheduleItem, TaskItem -logger = logging.getLogger("tableau.endpoint.schedules") +from tableauserverclient.helpers.logging import logger + AddResponse = namedtuple("AddResponse", ("result", "error", "warnings", "task_created")) OK = AddResponse(result=True, error=None, warnings=None, task_created=None) diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index b396a1f87..26aaf2910 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -1,15 +1,13 @@ import logging from .endpoint import Endpoint, api -from .exceptions import ( - ServerResponseError, +from .exceptions import ServerResponseError +from ..exceptions import ( ServerInfoEndpointNotFoundError, EndpointUnavailableError, ) from tableauserverclient.models import ServerInfoItem -logger = logging.getLogger("tableau.endpoint.server_info") - class ServerInfo(Endpoint): def __init__(self, server): diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index a4c765484..dfec49ae1 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import SiteItem, PaginationItem -logger = logging.getLogger("tableau.endpoint.sites") +from tableauserverclient.helpers.logging import logger from typing import TYPE_CHECKING, List, Optional, Tuple diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index a81a2fbf0..a9f2e7bf5 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -5,7 +5,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import SubscriptionItem, PaginationItem -logger = logging.getLogger("tableau.endpoint.subscriptions") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, TYPE_CHECKING, Tuple diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index e51f885d7..dfb2e6d7c 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -8,7 +8,7 @@ from tableauserverclient.models import TableItem, ColumnItem, PaginationItem from ..pager import Pager -logger = logging.getLogger("tableau.endpoint.tables") +from tableauserverclient.helpers.logging import logger class Tables(Endpoint): diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index b903ac634..ad1702f58 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -5,7 +5,7 @@ from tableauserverclient.models import TaskItem, PaginationItem from tableauserverclient.server import RequestFactory -logger = logging.getLogger("tableau.endpoint.tasks") +from tableauserverclient.helpers.logging import logger class Tasks(Endpoint): diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 5a9c74619..e8c5cc962 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -8,7 +8,7 @@ from tableauserverclient.models import UserItem, WorkbookItem, PaginationItem, GroupItem from ..pager import Pager -logger = logging.getLogger("tableau.endpoint.users") +from tableauserverclient.helpers.logging import logger class Users(QuerysetEndpoint): diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index c060298ba..9c4b90657 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -7,7 +7,7 @@ from .resource_tagger import _ResourceTagger from tableauserverclient.models import ViewItem, PaginationItem -logger = logging.getLogger("tableau.endpoint.views") +from tableauserverclient.helpers.logging import logger from typing import Iterator, List, Optional, Tuple, TYPE_CHECKING diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index 69a958988..597f9c425 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -4,7 +4,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import WebhookItem, PaginationItem -logger = logging.getLogger("tableau.endpoint.webhooks") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, TYPE_CHECKING, Tuple diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 295a4941f..dc4adafaa 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -44,7 +44,8 @@ ALLOWED_FILE_EXTENSIONS = ["twb", "twbx"] -logger = logging.getLogger("tableau.endpoint.workbooks") +from tableauserverclient.helpers.logging import logger + FilePath = Union[str, os.PathLike] FileObject = Union[io.BufferedReader, io.BytesIO] FileObjectR = Union[io.BufferedReader, io.BytesIO] diff --git a/tableauserverclient/server/exceptions.py b/tableauserverclient/server/exceptions.py index 09d3d0541..6c9bbcefc 100644 --- a/tableauserverclient/server/exceptions.py +++ b/tableauserverclient/server/exceptions.py @@ -1,2 +1,9 @@ -class NotSignedInError(Exception): +# These errors can be thrown without even talking to Tableau Server + + +class ServerInfoEndpointNotFoundError(Exception): + pass + + +class EndpointUnavailableError(Exception): pass diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index baedd74de..fa0a2d68a 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,7 +1,7 @@ from tableauserverclient.models.property_decorators import property_is_int import logging -logger = logging.getLogger("tableau.request_options") +from tableauserverclient.helpers.logging import logger class RequestOptionsBase(object): diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 887b9de6d..ee23789b1 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,4 +1,4 @@ -import logging +from tableauserverclient.helpers.logging import logger import requests import urllib3 @@ -34,11 +34,11 @@ Metrics, Endpoint, ) -from .endpoint.exceptions import ( +from .exceptions import ( ServerInfoEndpointNotFoundError, EndpointUnavailableError, ) -from .exceptions import NotSignedInError +from .endpoint.exceptions import NotSignedInError from ..namespace import Namespace @@ -99,8 +99,6 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.metrics = Metrics(self) self.custom_views = CustomViews(self) - self.logger = logging.getLogger("TSC.server") - self._session = self._session_factory() self._http_options = dict() # must set this before making a server call if http_options: @@ -114,7 +112,8 @@ def __init__(self, server_address, use_server_version=False, http_options=None, def validate_connection_settings(self): try: - Endpoint(self).set_parameters(self._http_options, None, None, None, None) + params = Endpoint(self).set_parameters(self._http_options, None, None, None, None) + Endpoint.set_user_agent(params) if not self._server_address.startswith("http://") and not self._server_address.startswith("https://"): self._server_address = "http://" + self._server_address self._session.prepare_request(requests.Request("GET", url=self._server_address, params=self._http_options)) @@ -156,8 +155,8 @@ def _get_legacy_version(self): try: info_xml = fromstring(response.content) except ParseError as parseError: - self.logger.info(parseError) - self.logger.info("Could not read server version info. The server may not be running or configured.") + logger.info(parseError) + logger.info("Could not read server version info. The server may not be running or configured.") return self.version prod_version = info_xml.find(".//product_version").text version = _PRODUCT_TO_REST_VERSION.get(prod_version, minimum_supported_server_version) @@ -168,15 +167,15 @@ def _determine_highest_version(self): old_version = self.version version = self.server_info.get().rest_api_version except ServerInfoEndpointNotFoundError as e: - self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = self._get_legacy_version() except EndpointUnavailableError as e: - self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = self._get_legacy_version() except Exception as e: - self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = None - self.logger.info("versions: {}, {}".format(version, old_version)) + logger.info("versions: {}, {}".format(version, old_version)) return version or old_version def use_server_version(self): @@ -184,7 +183,7 @@ def use_server_version(self): def use_highest_version(self): self.use_server_version() - self.logger.info("use use_server_version instead", DeprecationWarning) + logger.info("use use_server_version instead", DeprecationWarning) def check_at_least_version(self, target: str): server_version = Version(self.version or "2.4") diff --git a/test/test_auth.py b/test/test_auth.py index 40255f627..eaf13481e 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -63,7 +63,7 @@ def test_sign_in_error(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("testuser", "wrongpassword") - self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_invalid_token(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -71,7 +71,7 @@ def test_sign_in_invalid_token(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.PersonalAccessTokenAuth(token_name="mytoken", personal_access_token="invalid") - self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_without_auth(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -79,7 +79,7 @@ def test_sign_in_without_auth(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("", "") - self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) def test_sign_out(self): with open(SIGN_IN_XML, "rb") as f: diff --git a/test/test_datasource.py b/test/test_datasource.py index 4f3529762..730e382da 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -145,9 +145,9 @@ def test_update_copy_fields(self) -> None: def test_update_tags(self) -> None: add_tags_xml, update_xml = read_xml_assets(ADD_TAGS_XML, UPDATE_XML) with requests_mock.mock() as m: - m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags", text=add_tags_xml) m.delete(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/b", status_code=204) m.delete(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/d", status_code=204) + m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags", text=add_tags_xml) m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", text=update_xml) single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74") single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" @@ -191,7 +191,7 @@ def test_update_connection(self) -> None: self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections/be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", text=response_xml, ) - single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74") + single_datasource = TSC.DatasourceItem("be786ae0-d2bf-4a4b-9b34-e2de8d2d4488") single_datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" self.server.datasources.populate_connections(single_datasource) @@ -610,7 +610,7 @@ def test_synchronous_publish_timeout_error(self) -> None: new_datasource = TSC.DatasourceItem(project_id="") publish_mode = self.server.PublishMode.CreateNew - + # http://test/api/2.4/sites/dad65087-b08b-4603-af4e-2887b8aafc67/datasources?datasourceType=tds self.assertRaisesRegex( InternalServerError, "Please use asynchronous publishing to avoid timeouts.", diff --git a/test/test_endpoint.py b/test/test_endpoint.py index 0d8ae84f2..3d2d1c995 100644 --- a/test/test_endpoint.py +++ b/test/test_endpoint.py @@ -15,9 +15,32 @@ def setUp(self) -> None: # Fake signin self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - return super().setUp() + def test_fallback_request_logic(self) -> None: + url = "http://test/" + endpoint = TSC.server.Endpoint(self.server) + with requests_mock.mock() as m: + m.get(url) + response = endpoint.get_request(url=url) + self.assertIsNotNone(response) + + def test_user_friendly_request_returns(self) -> None: + url = "http://test/" + endpoint = TSC.server.Endpoint(self.server) + with requests_mock.mock() as m: + m.get(url) + response = endpoint.send_request_while_show_progress_threaded( + endpoint.parent_srv.session.get, url=url, request_timeout=2 + ) + self.assertIsNotNone(response) + + def test_blocking_request_returns(self) -> None: + url = "http://test/" + endpoint = TSC.server.Endpoint(self.server) + response = endpoint._blocking_request(endpoint.parent_srv.session.get, url=url) + self.assertIsNotNone(response) + def test_get_request_stream(self) -> None: url = "http://test/" endpoint = TSC.server.Endpoint(self.server) diff --git a/test/test_fileuploads.py b/test/test_fileuploads.py index 4d3b0c864..cf0861e24 100644 --- a/test/test_fileuploads.py +++ b/test/test_fileuploads.py @@ -43,7 +43,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(self.baseurl + "/" + upload_id, text=append_response_xml) + m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) actual = self.server.fileuploads.upload(file_path) self.assertEqual(upload_id, actual) @@ -58,7 +58,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(self.baseurl + "/" + upload_id, text=append_response_xml) + m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) actual = self.server.fileuploads.upload(file_content) self.assertEqual(upload_id, actual) diff --git a/test/test_request_option.py b/test/test_request_option.py index 9dacbe033..5d8bdf05e 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -22,7 +22,7 @@ class RequestOptionTests(unittest.TestCase): def setUp(self) -> None: - self.server = TSC.Server("http://test", False) + self.server = TSC.Server("http://test", False, http_options={"timeout": 5}) # Fake signin self.server.version = "3.10" @@ -151,7 +151,7 @@ def test_multiple_filter_options(self) -> None: ) ) req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "foo")) - for _ in range(100): + for _ in range(5): matching_workbooks, pagination_item = self.server.workbooks.get(req_option) self.assertEqual(3, pagination_item.total_available) @@ -245,7 +245,7 @@ def test_multiple_filter_options_shorthand(self) -> None: ) m.get(url, text=response_xml) - for _ in range(100): + for _ in range(5): matching_workbooks = self.server.workbooks.filter(tags__in=["sample", "safari", "weather"], name="foo") self.assertEqual(3, matching_workbooks.total_available) diff --git a/test/test_webhook.py b/test/test_webhook.py index ff8b7048e..5f26266b2 100644 --- a/test/test_webhook.py +++ b/test/test_webhook.py @@ -4,7 +4,8 @@ import requests_mock import tableauserverclient as TSC -from tableauserverclient.server import RequestFactory, WebhookItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import WebhookItem from ._utils import asset TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") From 830a3a61c8a85b9c0d8ed58a11b6e27ea98e2cf4 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 12 Apr 2023 18:22:33 -0700 Subject: [PATCH 03/23] Jac/small things (#1215) https://github.com/tableau/server-client-python/issues/1210 https://github.com/tableau/server-client-python/issues/1087 https://github.com/tableau/server-client-python/issues/1058 https://github.com/tableau/server-client-python/issues/456 https://github.com/tableau/server-client-python/issues/1209 --- tableauserverclient/models/datasource_item.py | 7 +++---- .../server/endpoint/workbooks_endpoint.py | 3 ++- tableauserverclient/server/request_factory.py | 19 ++++++++++++++----- tableauserverclient/server/request_options.py | 3 ++- test/test_datasource_model.py | 8 +++----- test/test_view.py | 16 ++++++++++++++++ 6 files changed, 40 insertions(+), 16 deletions(-) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index b5568a778..dbaa0ff91 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -32,7 +32,7 @@ def __repr__(self): self.project_id, ) - def __init__(self, project_id: str, name: Optional[str] = None) -> None: + def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) -> None: self._ask_data_enablement = None self._certified = None self._certification_note = None @@ -135,12 +135,11 @@ def id(self) -> Optional[str]: return self._id @property - def project_id(self) -> str: + def project_id(self) -> Optional[str]: return self._project_id @project_id.setter - @property_not_nullable - def project_id(self, value: str): + def project_id(self, value: Optional[str]): self._project_id = value @property diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index dc4adafaa..a73b0f0d5 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -310,6 +310,7 @@ def publish( as_job: bool = False, hidden_views: Optional[Sequence[str]] = None, skip_connection_check: bool = False, + parameters=None, ): if connection_credentials is not None: import warnings @@ -413,7 +414,7 @@ def publish( # Send the publishing request to server try: - server_response = self.post_request(url, xml_request, content_type) + server_response = self.post_request(url, xml_request, content_type, parameters) except InternalServerError as err: if err.code == 504 and not as_job: err.content = "Timeout error while publishing. Please use asynchronous publishing to avoid timeouts." diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index b19c3cc56..050874c91 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -9,6 +9,8 @@ if TYPE_CHECKING: from tableauserverclient.server import Server +# 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]: mime_multipart_parts = list() @@ -146,10 +148,11 @@ def update_req(self, database_item): class DatasourceRequest(object): - def _generate_xml(self, datasource_item, connection_credentials=None, connections=None): + def _generate_xml(self, datasource_item: DatasourceItem, connection_credentials=None, connections=None): xml_request = ET.Element("tsRequest") datasource_element = ET.SubElement(xml_request, "datasource") - datasource_element.attrib["name"] = datasource_item.name + if datasource_item.name: + datasource_element.attrib["name"] = datasource_item.name if datasource_item.description: datasource_element.attrib["description"] = str(datasource_item.description) if datasource_item.use_remote_query_agent is not None: @@ -157,10 +160,16 @@ def _generate_xml(self, datasource_item, connection_credentials=None, connection if datasource_item.ask_data_enablement: ask_data_element = ET.SubElement(datasource_element, "askData") - ask_data_element.attrib["enablement"] = datasource_item.ask_data_enablement + ask_data_element.attrib["enablement"] = datasource_item.ask_data_enablement.__str__() - project_element = ET.SubElement(datasource_element, "project") - project_element.attrib["id"] = datasource_item.project_id + if datasource_item.certified: + datasource_element.attrib["isCertified"] = datasource_item.certified.__str__() + if datasource_item.certification_note: + datasource_element.attrib["certificationNote"] = datasource_item.certification_note + + if datasource_item.project_id: + project_element = ET.SubElement(datasource_element, "project") + project_element.attrib["id"] = datasource_item.project_id if connection_credentials is not None and connections is not None: raise RuntimeError("You cannot set both `connections` and `connection_credentials`") diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index fa0a2d68a..1ee18e9df 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -38,6 +38,7 @@ class Operator: class Field: Args = "args" CompletedAt = "completedAt" + ContentUrl = "contentUrl" CreatedAt = "createdAt" DomainName = "domainName" DomainNickname = "domainNickname" @@ -147,7 +148,7 @@ def get_query_params(self): return params -class ExcelRequestOptions(RequestOptionsBase): +class ExcelRequestOptions(_FilterOptionsBase): def __init__(self, maxage: int = -1) -> None: super().__init__() self.max_age = maxage diff --git a/test/test_datasource_model.py b/test/test_datasource_model.py index 2360574ec..655284194 100644 --- a/test/test_datasource_model.py +++ b/test/test_datasource_model.py @@ -3,11 +3,9 @@ class DatasourceModelTests(unittest.TestCase): - def test_invalid_project_id(self): - self.assertRaises(ValueError, TSC.DatasourceItem, None) - datasource = TSC.DatasourceItem("10") - with self.assertRaises(ValueError): - datasource.project_id = None + def test_nullable_project_id(self): + datasource = TSC.DatasourceItem(name="10") + self.assertEqual(datasource.project_id, None) def test_require_boolean_flag_bridge_fail(self): datasource = TSC.DatasourceItem("10") diff --git a/test/test_view.py b/test/test_view.py index f5d3db47b..1459150bb 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -299,3 +299,19 @@ def test_populate_excel(self) -> None: excel_file = b"".join(single_view.excel) self.assertEqual(response, excel_file) + + def test_filter_excel(self) -> None: + self.server.version = "3.8" + self.baseurl = self.server.views.baseurl + with open(POPULATE_EXCEL, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/crosstab/excel?maxAge=1", content=response) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + request_option = TSC.ExcelRequestOptions(maxage=1) + request_option.vf("stuff", "1") + self.server.views.populate_excel(single_view, request_option) + + excel_file = b"".join(single_view.excel) + self.assertEqual(response, excel_file) From a29d6ebb6e6f95600567f72129b18be83c82f314 Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 24 Apr 2023 12:03:04 -0700 Subject: [PATCH 04/23] update datasource to use bridge (#1224) Update request_factory.py --- tableauserverclient/server/request_factory.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 050874c91..91a120512 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -197,6 +197,8 @@ def update_req(self, datasource_item): if datasource_item.owner_id: owner_element = ET.SubElement(datasource_element, "owner") owner_element.attrib["id"] = datasource_item.owner_id + if datasource_item.use_remote_query_agent is not None: + datasource_element.attrib["useRemoteQueryAgent"] = str(datasource_item.use_remote_query_agent).lower() datasource_element.attrib["isCertified"] = str(datasource_item.certified).lower() From beda2d88057c3b1da3f3aba536e25f28aa73c4ca Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Mon, 24 Apr 2023 12:10:37 -0700 Subject: [PATCH 05/23] fix imports --- .../server/endpoint/favorites_endpoint.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index d33452b30..ac9e4b185 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,25 +1,22 @@ -import logging - from .endpoint import Endpoint, api from requests import Response -from tableauserverclient.server import RequestFactory, RequestOptions, Resource +from tableauserverclient.helpers.logging import logger from tableauserverclient.models import ( DatasourceItem, FavoriteItem, FlowItem, MetricItem, ProjectItem, + Resource, + TableauItem, UserItem, ViewItem, WorkbookItem, - TableauItem, ) - +from tableauserverclient.server import RequestFactory, RequestOptions from typing import Optional -from tableauserverclient.helpers.logging import logger - class Favorites(Endpoint): @property From 5650adc5126a7fc0070fb8e68de848af709baa0d Mon Sep 17 00:00:00 2001 From: Lars Breddemann <139097050+LarsBreddemann@users.noreply.github.com> Date: Tue, 25 Jul 2023 15:48:17 +1000 Subject: [PATCH 06/23] 846 fix filter in operator spaces bug (#1259) * encode spaces in filter conditions as %20 * corrected string replacement for filter condition * removed trailing space from comment * added tests for filter with IN condition and spaces in names --- tableauserverclient/server/filter.py | 6 ++++- test/assets/request_option_filter_name_in.xml | 12 +++++++++ test/test_filter.py | 22 ++++++++++++++++ test/test_request_option.py | 25 +++++++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 test/assets/request_option_filter_name_in.xml create mode 100644 test/test_filter.py diff --git a/tableauserverclient/server/filter.py b/tableauserverclient/server/filter.py index 8802321fd..b936ceb92 100644 --- a/tableauserverclient/server/filter.py +++ b/tableauserverclient/server/filter.py @@ -11,7 +11,11 @@ def __init__(self, field, operator, value): def __str__(self): value_string = str(self._value) if isinstance(self._value, list): - value_string = value_string.replace(" ", "").replace("'", "") + # this should turn the string representation of the list + # from ['', '', ...] + # 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) @property diff --git a/test/assets/request_option_filter_name_in.xml b/test/assets/request_option_filter_name_in.xml new file mode 100644 index 000000000..9ec42b8ab --- /dev/null +++ b/test/assets/request_option_filter_name_in.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_filter.py b/test/test_filter.py new file mode 100644 index 000000000..e2121307f --- /dev/null +++ b/test/test_filter.py @@ -0,0 +1,22 @@ +import os +import unittest + +import tableauserverclient as TSC + + +class FilterTests(unittest.TestCase): + def setUp(self): + pass + + def test_filter_equal(self): + filter = TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "Superstore") + + self.assertEqual(str(filter), "name:eq:Superstore") + + def test_filter_in(self): + # create a IN filter condition with project names that + # contain spaces and "special" characters + projects_to_find = ["default", "Salesforce Sales Projeśt"] + filter = TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, projects_to_find) + + self.assertEqual(str(filter), "name:in:[default,Salesforce Sales Projeśt]") diff --git a/test/test_request_option.py b/test/test_request_option.py index 5d8bdf05e..32526d1e6 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -13,6 +13,7 @@ PAGE_NUMBER_XML = os.path.join(TEST_ASSET_DIR, "request_option_page_number.xml") PAGE_SIZE_XML = os.path.join(TEST_ASSET_DIR, "request_option_page_size.xml") FILTER_EQUALS = os.path.join(TEST_ASSET_DIR, "request_option_filter_equals.xml") +FILTER_NAME_IN = os.path.join(TEST_ASSET_DIR, "request_option_filter_name_in.xml") FILTER_TAGS_IN = os.path.join(TEST_ASSET_DIR, "request_option_filter_tags_in.xml") FILTER_MULTIPLE = os.path.join(TEST_ASSET_DIR, "request_option_filter_tags_in.xml") SLICING_QUERYSET = os.path.join(TEST_ASSET_DIR, "request_option_slicing_queryset.xml") @@ -114,6 +115,30 @@ def test_filter_tags_in(self) -> None: self.assertEqual(set(["safari"]), matching_workbooks[1].tags) self.assertEqual(set(["sample"]), matching_workbooks[2].tags) + # check if filtered projects with spaces & special characters + # get correctly returned + def test_filter_name_in(self) -> None: + with open(FILTER_NAME_IN, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get( + self.baseurl + "/projects?filter=name%3Ain%3A%5Bdefault%2CSalesforce+Sales+Proje%C5%9Bt%5D", + text=response_xml, + ) + req_option = TSC.RequestOptions() + req_option.filter.add( + TSC.Filter( + TSC.RequestOptions.Field.Name, + TSC.RequestOptions.Operator.In, + ["default", "Salesforce Sales Projeśt"], + ) + ) + matching_projects, pagination_item = self.server.projects.get(req_option) + + self.assertEqual(2, pagination_item.total_available) + self.assertEqual("default", matching_projects[0].name) + self.assertEqual("Salesforce Sales Projeśt", matching_projects[1].name) + def test_filter_tags_in_shorthand(self) -> None: with open(FILTER_TAGS_IN, "rb") as f: response_xml = f.read().decode("utf-8") From 66064c55b0aee851dba31d50b0ab7c23e0270acf Mon Sep 17 00:00:00 2001 From: jorwoods Date: Mon, 31 Jul 2023 15:46:13 -0500 Subject: [PATCH 07/23] fix: remove logging configuration from TSC (#1248) Co-authored-by: Jac Co-authored-by: Tim Payne <47423639+ma7tcsp@users.noreply.github.com> --- tableauserverclient/helpers/logging.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tableauserverclient/helpers/logging.py b/tableauserverclient/helpers/logging.py index 414d85786..e64c6d2c8 100644 --- a/tableauserverclient/helpers/logging.py +++ b/tableauserverclient/helpers/logging.py @@ -2,5 +2,3 @@ # TODO change: this defaults to logging *everything* to stdout logger = logging.getLogger("TSC") -logger.setLevel(logging.DEBUG) -logger.addHandler(logging.StreamHandler()) From f56b2c741d7e94630ec1b2d1d6ee4e9c31c5093f Mon Sep 17 00:00:00 2001 From: jorwoods Date: Tue, 1 Aug 2023 02:17:00 -0500 Subject: [PATCH 08/23] feat: add JWTAuth (#1219) * feat: add JWTAuth, add repr using qualname * chore: mark Credentials class and methods as abstract --- tableauserverclient/models/tableau_auth.py | 30 +++++++++++++++++-- .../server/endpoint/auth_endpoint.py | 28 +++++++++++++---- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index db21e4aa2..30639d09b 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -1,13 +1,18 @@ -class Credentials: +import abc + + +class Credentials(abc.ABC): def __init__(self, site_id=None, user_id_to_impersonate=None): self.site_id = site_id or "" self.user_id_to_impersonate = user_id_to_impersonate or None @property + @abc.abstractmethod def credentials(self): 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" + @abc.abstractmethod def __repr__(self): return "All Credentials types must have a debug display that does not print secrets" @@ -52,10 +57,10 @@ def site(self, value): class PersonalAccessTokenAuth(Credentials): - def __init__(self, token_name, personal_access_token, site_id=None): + def __init__(self, token_name, personal_access_token, site_id=None, user_id_to_impersonate=None): if personal_access_token is None or token_name is None: raise TabError("Must provide a token and token name when using PAT authentication") - super().__init__(site_id=site_id) + super().__init__(site_id=site_id, user_id_to_impersonate=user_id_to_impersonate) self.token_name = token_name self.personal_access_token = personal_access_token @@ -70,3 +75,22 @@ def __repr__(self): return "(site={})".format( self.token_name, self.personal_access_token[:2] + "...", self.site_id ) + + +class JWTAuth(Credentials): + def __init__(self, jwt=None, site_id=None, user_id_to_impersonate=None): + if jwt is None: + raise TabError("Must provide a JWT token when using JWT authentication") + super().__init__(site_id, user_id_to_impersonate) + self.jwt = jwt + + @property + def credentials(self): + return {"jwt": self.jwt} + + def __repr__(self): + if self.user_id_to_impersonate: + uid = f", user_id_to_impersonate=f{self.user_id_to_impersonate}" + else: + uid = "" + return f"<{self.__class__.__qualname__}(jwt={self.jwt[:5]}..., site_id={self.site_id}{uid})>" diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 6f1ddc35e..2025de5fb 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -1,4 +1,6 @@ import logging +from typing import TYPE_CHECKING +import warnings from defusedxml.ElementTree import fromstring @@ -8,6 +10,10 @@ from tableauserverclient.helpers.logging import logger +if TYPE_CHECKING: + from tableauserverclient.models.site_item import SiteItem + from tableauserverclient.models.tableau_auth import Credentials + class Auth(Endpoint): class contextmgr(object): @@ -21,11 +27,21 @@ def __exit__(self, exc_type, exc_val, exc_tb): self._callback() @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/auth".format(self.parent_srv.baseurl) @api(version="2.0") - def sign_in(self, auth_req): + def sign_in(self, auth_req: "Credentials") -> contextmgr: + """ + Sign in to a Tableau Server or Tableau Online using a credentials object. + + The credentials object can either be a TableauAuth object, a + PersonalAccessTokenAuth object, or a JWTAuth object. This method now + accepts them all. The object should be populated with the site_id and + optionally a user_id to impersonate. + + Creates a context manager that will sign out of the server upon exit. + """ url = "{0}/{1}".format(self.baseurl, "signin") signin_req = RequestFactory.Auth.signin_req(auth_req) server_response = self.parent_srv.session.post( @@ -51,12 +67,12 @@ def sign_in(self, auth_req): return Auth.contextmgr(self.sign_out) @api(version="3.6") - def sign_in_with_personal_access_token(self, auth_req): + def sign_in_with_personal_access_token(self, auth_req: "Credentials") -> contextmgr: # We use the same request that username/password login uses. return self.sign_in(auth_req) @api(version="2.0") - def sign_out(self): + def sign_out(self) -> None: url = "{0}/{1}".format(self.baseurl, "signout") # If there are no auth tokens you're already signed out. No-op if not self.parent_srv.is_signed_in(): @@ -66,7 +82,7 @@ def sign_out(self): logger.info("Signed out") @api(version="2.6") - def switch_site(self, site_item): + def switch_site(self, site_item: "SiteItem") -> contextmgr: url = "{0}/{1}".format(self.baseurl, "switchSite") switch_req = RequestFactory.Auth.switch_req(site_item.content_url) try: @@ -87,7 +103,7 @@ def switch_site(self, site_item): return Auth.contextmgr(self.sign_out) @api(version="3.10") - def revoke_all_server_admin_tokens(self): + def revoke_all_server_admin_tokens(self) -> None: url = "{0}/{1}".format(self.baseurl, "revokeAllServerAdminTokens") self.post_request(url, "") logger.info("Revoked all tokens for all server admins") From 77f2f63e62c860bc7e9f25a2f268e8b0a5e078ac Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 1 Aug 2023 15:30:18 -0700 Subject: [PATCH 09/23] Jac/schedules (#1266) * Hotfix schedule_item.py for issue 1237 (#1239) * Remove duplicate assignments to fields (#1244) Co-authored-by: Tim Payne <47423639+ma7tcsp@users.noreply.github.com> Co-authored-by: Austin <110413815+austinpeters-gohealthuccom@users.noreply.github.com> Co-authored-by: Yasuhisa Yoshida --- tableauserverclient/models/datasource_item.py | 9 --------- tableauserverclient/models/schedule_item.py | 8 ++------ 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 7fcc31ebf..5a867135c 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -326,17 +326,8 @@ def _parse_element(datasource_xml: ET.Element, ns: Dict) -> Tuple: updated_at = parse_datetime(datasource_xml.get("updatedAt", None)) certification_note = datasource_xml.get("certificationNote", None) certified = str(datasource_xml.get("isCertified", None)).lower() == "true" - certification_note = datasource_xml.get("certificationNote", None) - certified = str(datasource_xml.get("isCertified", None)).lower() == "true" - content_url = datasource_xml.get("contentUrl", None) - created_at = parse_datetime(datasource_xml.get("createdAt", None)) - datasource_type = datasource_xml.get("type", None) - description = datasource_xml.get("description", None) encrypt_extracts = datasource_xml.get("encryptExtracts", None) has_extracts = datasource_xml.get("hasExtracts", None) - id_ = datasource_xml.get("id", None) - name = datasource_xml.get("name", None) - updated_at = parse_datetime(datasource_xml.get("updatedAt", None)) use_remote_query_agent = datasource_xml.get("useRemoteQueryAgent", None) webpage_url = datasource_xml.get("webpageUrl", None) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index 54e4badbe..edfd0fe70 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -14,8 +14,6 @@ ) from .property_decorators import ( property_is_enum, - property_not_nullable, - property_is_int, ) Interval = Union[HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval] @@ -27,6 +25,7 @@ class Type: Flow = "Flow" Subscription = "Subscription" DataAcceleration = "DataAcceleration" + ActiveDirectorySync = "ActiveDirectorySync" class ExecutionOrder: Parallel = "Parallel" @@ -74,11 +73,10 @@ def id(self) -> Optional[str]: return self._id @property - def name(self) -> str: + def name(self) -> Optional[str]: return self._name @name.setter - @property_not_nullable def name(self, value: str): self._name = value @@ -91,7 +89,6 @@ def priority(self) -> int: return self._priority @priority.setter - @property_is_int(range=(1, 100)) def priority(self, value: int): self._priority = value @@ -101,7 +98,6 @@ def schedule_type(self) -> str: @schedule_type.setter @property_is_enum(Type) - @property_not_nullable def schedule_type(self, value: str): self._schedule_type = value From 574118aed0a8a089fa482fa856e79f62d9c6fdd5 Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 1 Aug 2023 16:29:14 -0700 Subject: [PATCH 10/23] add powerpoint example in samples (#1262) --- samples/explore_workbook.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index c61b9b637..57f88aa07 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -36,6 +36,9 @@ def main(): parser.add_argument( "--preview-image", "-i", metavar="FILENAME", help="filename (a .png file) to save the preview image" ) + parser.add_argument( + "--powerpoint", "-ppt", metavar="FILENAME", help="filename (a .ppt file) to save the powerpoint deck" + ) args = parser.parse_args() @@ -145,6 +148,13 @@ def main(): f.write(c.image) print("saved to " + filename) + if args.powerpoint: + # Populate workbook preview image + 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))) + if args.delete: print("deleting {}".format(c.id)) unlucky = TSC.CustomViewItem(c.id) From 4caf0a5a948ed50a4d259c8ab65f5552b8544ace Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 1 Aug 2023 16:30:31 -0700 Subject: [PATCH 11/23] pin black and mypy versions, update many dependencies (#1265) * pin black and mypy versions * drop python 3.7 support - update build + dependencies to latest versions - check mypy warnings, fix 2 typing complaints * update python versions used in actions to 3.8 -- 3.12 ( github doesn't have an image for 3.12 yet b/c it is still in beta?) --- .github/workflows/run-tests.yml | 4 ++-- pyproject.toml | 26 ++++++++++++++------------ test/models/test_repr.py | 2 +- test/test_datasource.py | 6 ++++-- 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 10df02c04..1f4614088 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -8,7 +8,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.7', '3.8', '3.9', '3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11'] runs-on: ${{ matrix.os }} @@ -33,4 +33,4 @@ jobs: - name: Test build if: always() run: | - python -m build \ No newline at end of file + python -m build diff --git a/pyproject.toml b/pyproject.toml index ee793ec41..717ca7cde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=45.0", "versioneer>=0.24", "wheel"] +requires = ["setuptools>=68.0", "versioneer>=0.29", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -12,39 +12,41 @@ license = {file = "LICENSE"} readme = "README.md" dependencies = [ - 'defusedxml>=0.7.1', - 'packaging>=22.0', # bumping to minimum version required by black - 'requests>=2.28', - 'urllib3~=1.26.8', + '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.0.4', # latest as at 7/31/23 ] requires-python = ">=3.7" classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10" + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12" ] [project.urls] repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["argparse", "black", "mock", "mypy", "pytest>=7.0", "pytest-subtests", "requests-mock>=1.0,<2.0"] +test = ["argparse", "black==23.7", "mock", "mypy==1.4", "pytest>=7.0", "pytest-subtests", "requests-mock>=1.0,<2.0"] [tool.black] line-length = 120 -target-version = ['py37', 'py38', 'py39', 'py310'] +target-version = ['py37', 'py38', 'py39', 'py310', 'py311', 'py312'] [tool.mypy] +check_untyped_defs = false disable_error_code = [ 'misc', - 'import' + # 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"] show_error_codes = true -ignore_missing_imports = true - +ignore_missing_imports = true # defusedxml library has no types [tool.pytest.ini_options] testpaths = ["test"] addopts = "--junitxml=./test.junit.xml" diff --git a/test/models/test_repr.py b/test/models/test_repr.py index f3da9fde2..d21e4bc4a 100644 --- a/test/models/test_repr.py +++ b/test/models/test_repr.py @@ -1,7 +1,7 @@ import pytest from unittest import TestCase -import _models +import _models # type: ignore # did not set types for this # ensure that all models have a __repr__ method implemented diff --git a/test/test_datasource.py b/test/test_datasource.py index 730e382da..e299e5291 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -2,12 +2,14 @@ import tempfile import unittest from io import BytesIO +from typing import Optional from zipfile import ZipFile import requests_mock from defusedxml.ElementTree import fromstring import tableauserverclient as TSC +from tableauserverclient import ConnectionItem from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.server.endpoint.exceptions import InternalServerError from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads @@ -167,9 +169,9 @@ def test_populate_connections(self) -> None: single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" self.server.datasources.populate_connections(single_datasource) self.assertEqual("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", single_datasource.id) - connections = single_datasource.connections + connections: Optional[list[ConnectionItem]] = single_datasource.connections - self.assertTrue(connections) + self.assertIsNotNone(connections) ds1, ds2 = connections self.assertEqual("be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", ds1.id) self.assertEqual("textscan", ds1.connection_type) From 282159291c0506a9d1ea79077227b59fb032e7e0 Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 1 Aug 2023 16:31:11 -0700 Subject: [PATCH 12/23] Add publish samples attribute (#1264) --- samples/create_project.py | 9 ++++++++- tableauserverclient/models/project_item.py | 2 ++ tableauserverclient/server/endpoint/projects_endpoint.py | 2 ++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/samples/create_project.py b/samples/create_project.py index 611dbe366..1fc649f8c 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -57,7 +57,14 @@ def main(): server.use_server_version() # Without parent_id specified, projects are created at the top level. - top_level_project = TSC.ProjectItem(name="Top Level Project") + # With the publish-samples attribute, the project will be created with sample items + top_level_project = TSC.ProjectItem( + name="Top Level Project", + description="A sample tsc project", + content_permissions=None, + parent_id=None, + samples=True, + ) top_level_project = create_project(server, top_level_project) # Specifying parent_id creates a nested projects. diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 393a7990f..e7254ab5d 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -25,6 +25,7 @@ def __init__( description: Optional[str] = None, content_permissions: Optional[str] = None, parent_id: Optional[str] = None, + samples: Optional[bool] = None, ) -> None: self._content_permissions = None self._id: Optional[str] = None @@ -32,6 +33,7 @@ def __init__( self.name: str = name self.content_permissions: Optional[str] = content_permissions self.parent_id: Optional[str] = parent_id + self._samples: Optional[bool] = samples self._permissions = None self._default_workbook_permissions = None diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 510f1ff3d..99bb2e39b 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -63,6 +63,8 @@ def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem: params = {"params": {RequestOptions.Field.PublishSamples: samples}} url = self.baseurl + if project_item._samples: + url = "{0}?publishSamples={1}".format(self.baseurl, project_item._samples) create_req = RequestFactory.Project.create_req(project_item) server_response = self.post_request(url, create_req, XML_CONTENT_TYPE, params) new_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0] From 90cf3329d8298d41d0ea941f97f2b0e501fc8e0c Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 1 Aug 2023 16:45:15 -0700 Subject: [PATCH 13/23] Update actions to newer versions to get supported Node versions (#1267) --- .github/workflows/code-coverage.yml | 4 ++-- .github/workflows/meta-checks.yml | 4 ++-- .github/workflows/publish-pypi.yml | 2 +- .github/workflows/run-tests.yml | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 6d74c5c38..d858c3389 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -16,10 +16,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml index 3fcb852d1..7d6cd068a 100644 --- a/.github/workflows/meta-checks.yml +++ b/.github/workflows/meta-checks.yml @@ -13,10 +13,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index b8a70e9c5..fe8fffc42 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 0 - - uses: actions/setup-python@v1 + - uses: actions/setup-python@v4 with: python-version: 3.7 - name: Build dist files diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 1f4614088..3df497806 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,10 +13,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} From 15086b8cc200341255feed5392a82f4a8fa6895d Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 1 Aug 2023 16:45:32 -0700 Subject: [PATCH 14/23] Added 'getting started' samples (#1263) * Added 'getting started' samples --- samples/getting_started/1_hello_server.py | 21 +++++ samples/getting_started/2_hello_site.py | 50 +++++++++++ samples/getting_started/3_hello_universe.py | 96 +++++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 samples/getting_started/1_hello_server.py create mode 100644 samples/getting_started/2_hello_site.py create mode 100644 samples/getting_started/3_hello_universe.py diff --git a/samples/getting_started/1_hello_server.py b/samples/getting_started/1_hello_server.py new file mode 100644 index 000000000..454b225de --- /dev/null +++ b/samples/getting_started/1_hello_server.py @@ -0,0 +1,21 @@ +#### +# Getting started Part One of Three +# This script demonstrates how to use the Tableau Server Client to connect to a server +# You don't need to have a site or any experience with Tableau to run it +# +#### + +import tableauserverclient as TSC + + +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("Sign up for a test site at https://www.tableau.com/developer") + + +if __name__ == "__main__": + main() diff --git a/samples/getting_started/2_hello_site.py b/samples/getting_started/2_hello_site.py new file mode 100644 index 000000000..d62896059 --- /dev/null +++ b/samples/getting_started/2_hello_site.py @@ -0,0 +1,50 @@ +#### +# Getting started Part Two of Three +# This script demonstrates how to use the Tableau Server Client to +# view the content on an existing site on Tableau Server/Online +# It assumes that you have already got a site and can visit it in a browser +# +#### + +import getpass +import tableauserverclient as TSC + + +# 0 - launch your Tableau site in a web browser and look at the url to set the values below +def main(): + # 1 - replace with your server domain: stop at the slash + server_url = "https://10ax.online.tableau.com" + + # 2 - optional - change to false **for testing only** if you get a certificate error + 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)) + + # 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 + site_url_name = "" # leave empty if there is no site name in the url (you are on the default site) + + # 4 - replace with your username. + # REMEMBER: if you are using Tableau Online, your username is the entire email address + username = "your-username-here" + password = getpass.getpass("Your password:") # so you don't save it in this file + tableau_auth = TSC.TableauAuth(username, password, site_id=site_url_name) + + # OR instead of username+password, uncomment this section to use a Personal Access Token + # token_name = "your-token-name" + # token_value = "your-token-value-long-random-string" + # tableau_auth = TSC.PersonalAccessTokenAuth(token_name, token_value, site_id=site_url_name) + + with server.auth.sign_in(tableau_auth): + projects, pagination = server.projects.get() + if projects: + print("{} projects".format(pagination.total_available)) + project = projects[0] + print(project.name) + + print("Done") + + +if __name__ == "__main__": + main() diff --git a/samples/getting_started/3_hello_universe.py b/samples/getting_started/3_hello_universe.py new file mode 100644 index 000000000..3ed39fd17 --- /dev/null +++ b/samples/getting_started/3_hello_universe.py @@ -0,0 +1,96 @@ +#### +# Getting Started Part Three of Three +# This script demonstrates all the different types of 'content' a server contains +# +# To make it easy to run, it doesn't take any arguments - you need to edit the code with your info +#### + +import getpass +import tableauserverclient as TSC + + +def main(): + # 1 - replace with your server url + server_url = "https://10ax.online.tableau.com" + + # 2 - change to false **for testing only** if you get a certificate error + 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)) + + # 3 - replace with your site name exactly as it looks in a url + # e.g https://my-server/#/this-is-your-site-url-name/ + site_url_name = "" # leave empty if there is no site name in the url (you are on the default site) + + # 4 + username = "your-username-here" + password = getpass.getpass("Your password:") # so you don't save it in this file + tableau_auth = TSC.TableauAuth(username, password, site_id=site_url_name) + + # OR instead of username+password, use a Personal Access Token (PAT) (required by Tableau Cloud) + # token_name = "your-token-name" + # token_value = "your-token-value-long-random-string" + # tableau_auth = TSC.PersonalAccessTokenAuth(token_name, token_value, site_id=site_url_name) + + with server.auth.sign_in(tableau_auth): + projects, pagination = server.projects.get() + if projects: + print("{} projects".format(pagination.total_available)) + for project in projects: + print(project.name) + + workbooks, pagination = server.datasources.get() + if workbooks: + print("{} workbooks".format(pagination.total_available)) + print(workbooks[0]) + + views, pagination = server.views.get() + if views: + print("{} views".format(pagination.total_available)) + print(views[0]) + + datasources, pagination = server.datasources.get() + if datasources: + print("{} datasources".format(pagination.total_available)) + print(datasources[0]) + + # I think all these other content types can go to a hello_universe script + # data alert, dqw, flow, ... do any of these require any add-ons? + jobs, pagination = server.jobs.get() + if jobs: + print("{} jobs".format(pagination.total_available)) + print(jobs[0]) + + metrics, pagination = server.metrics.get() + if metrics: + print("{} metrics".format(pagination.total_available)) + print(metrics[0]) + + schedules, pagination = server.schedules.get() + if schedules: + print("{} schedules".format(pagination.total_available)) + print(schedules[0]) + + tasks, pagination = server.tasks.get() + if tasks: + print("{} tasks".format(pagination.total_available)) + print(tasks[0]) + + webhooks, pagination = server.webhooks.get() + if webhooks: + print("{} webhooks".format(pagination.total_available)) + print(webhooks[0]) + + users, pagination = server.metrics.get() + if users: + print("{} users".format(pagination.total_available)) + print(users[0]) + + groups, pagination = server.groups.get() + if groups: + print("{} groups".format(pagination.total_available)) + print(groups[0]) + + if __name__ == "__main__": + main() From 01e03727a8c82d13d929c0abcb8c0136dc6a5a40 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Thu, 17 Aug 2023 11:56:54 -0700 Subject: [PATCH 15/23] Fix newline for clean Black run --- tableauserverclient/helpers/logging.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tableauserverclient/helpers/logging.py b/tableauserverclient/helpers/logging.py index 860bed0fc..e64c6d2c8 100644 --- a/tableauserverclient/helpers/logging.py +++ b/tableauserverclient/helpers/logging.py @@ -2,4 +2,3 @@ # TODO change: this defaults to logging *everything* to stdout logger = logging.getLogger("TSC") - From 5a5772ca804502dcfbba9e7b3674e3fc68722459 Mon Sep 17 00:00:00 2001 From: a-torres-2 <142839181+a-torres-2@users.noreply.github.com> Date: Tue, 29 Aug 2023 12:00:25 -0700 Subject: [PATCH 16/23] add support for custom schedules in TOL (#1273) * add support for custom schedules in TOL --- samples/create_extract_task.py | 84 +++++++++++++++++++ .../models/subscription_item.py | 8 ++ .../server/endpoint/tasks_endpoint.py | 11 +++ tableauserverclient/server/request_factory.py | 28 +++++++ test/assets/tasks_create_extract_task.xml | 12 +++ test/test_task.py | 27 +++++- 6 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 samples/create_extract_task.py create mode 100644 test/assets/tasks_create_extract_task.xml diff --git a/samples/create_extract_task.py b/samples/create_extract_task.py new file mode 100644 index 000000000..8408f67ee --- /dev/null +++ b/samples/create_extract_task.py @@ -0,0 +1,84 @@ +#### +# This script demonstrates how to create extract tasks in Tableau Cloud +# using the Tableau Server Client. +# +# To run the script, you must have installed Python 3.7 or later. +#### + + +import argparse +import logging + +from datetime import time + +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description="Creates sample extract refresh task.") + # 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): + # Monthly Schedule + # This schedule will run on the 15th of every month at 11:30PM + monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), interval_value=15) + monthly_schedule = TSC.ScheduleItem( + None, + None, + None, + None, + monthly_interval, + ) + + # Default to using first workbook found in server + all_workbook_items, pagination_item = server.workbooks.get() + my_workbook: TSC.WorkbookItem = all_workbook_items[0] + + target_item = TSC.Target( + my_workbook.id, # the id of the workbook or datasource + "workbook", # alternatively can be "datasource" + ) + + extract_item = TSC.TaskItem( + None, + "FullRefresh", + None, + None, + None, + monthly_schedule, + None, + target_item, + ) + + try: + response = server.tasks.create(extract_item) + print(response) + except Exception as e: + print(e) + + +if __name__ == "__main__": + main() diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index e18adc6ae..e96fcc448 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -4,6 +4,7 @@ from .property_decorators import property_is_boolean from .target import Target +from tableauserverclient.models import ScheduleItem if TYPE_CHECKING: from .target import Target @@ -23,6 +24,7 @@ def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target self.suspended = False self.target = target self.user_id = user_id + self.schedule = None def __repr__(self) -> str: if self.id is not None: @@ -92,9 +94,14 @@ def _parse_element(cls, element, ns): # Schedule element schedule_id = None + schedule = None if schedule_element is not None: schedule_id = schedule_element.get("id", None) + # If schedule id is not provided, then TOL with full schedule provided + if schedule_id is None: + schedule = ScheduleItem.from_element(element, ns) + # Content element target = None send_if_view_empty = None @@ -127,6 +134,7 @@ def _parse_element(cls, element, ns): sub.page_size_option = page_size_option sub.send_if_view_empty = send_if_view_empty sub.suspended = suspended + sub.schedule = schedule return sub diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index ad1702f58..092597388 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -51,6 +51,17 @@ def get_by_id(self, task_id): server_response = self.get_request(url) return TaskItem.from_response(server_response.content, self.parent_srv.namespace)[0] + @api(version="3.19") + def create(self, extract_item: TaskItem) -> TaskItem: + if not extract_item: + error = "No extract refresh provided" + raise ValueError(error) + logger.info("Creating an extract refresh ({})".format(extract_item)) + url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh)) + create_req = RequestFactory.Task.create_extract_req(extract_item) + server_response = self.post_request(url, create_req) + return server_response.content + @api(version="2.6") def run(self, task_item): if not task_item.id: diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 4bd30bb2c..7fb9bf9ed 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1028,6 +1028,34 @@ def run_req(self, xml_request, task_item): # Send an empty tsRequest pass + @_tsrequest_wrapped + def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") -> bytes: + extract_element = ET.SubElement(xml_request, "extractRefresh") + + # Schedule attributes + schedule_element = ET.SubElement(xml_request, "schedule") + + interval_item = extract_item.schedule_item.interval_item + schedule_element.attrib["frequency"] = interval_item._frequency + frequency_element = ET.SubElement(schedule_element, "frequencyDetails") + frequency_element.attrib["start"] = str(interval_item.start_time) + if hasattr(interval_item, "end_time") and interval_item.end_time is not None: + frequency_element.attrib["end"] = str(interval_item.end_time) + if hasattr(interval_item, "interval") and interval_item.interval: + intervals_element = ET.SubElement(frequency_element, "intervals") + for interval in interval_item._interval_type_pairs(): + expression, value = interval + single_interval_element = ET.SubElement(intervals_element, "interval") + single_interval_element.attrib[expression] = value + + # Main attributes + extract_element.attrib["type"] = extract_item.task_type + + target_element = ET.SubElement(extract_element, extract_item.target.type) + target_element.attrib["id"] = extract_item.target.id + + return ET.tostring(xml_request) + class SubscriptionRequest(object): @_tsrequest_wrapped diff --git a/test/assets/tasks_create_extract_task.xml b/test/assets/tasks_create_extract_task.xml new file mode 100644 index 000000000..9e6310fba --- /dev/null +++ b/test/assets/tasks_create_extract_task.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_task.py b/test/test_task.py index 5c432208d..4eb2c02e2 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -1,5 +1,6 @@ import os import unittest +from datetime import time import requests_mock @@ -15,12 +16,13 @@ GET_XML_WITH_WORKBOOK_AND_DATASOURCE = os.path.join(TEST_ASSET_DIR, "tasks_with_workbook_and_datasource.xml") GET_XML_DATAACCELERATION_TASK = os.path.join(TEST_ASSET_DIR, "tasks_with_dataacceleration_task.xml") GET_XML_RUN_NOW_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_run_now_response.xml") +GET_XML_CREATE_TASK_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_create_extract_task.xml") class TaskTests(unittest.TestCase): def setUp(self): self.server = TSC.Server("http://test", False) - self.server.version = "3.8" + self.server.version = "3.19" # Fake Signin self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" @@ -141,3 +143,26 @@ def test_run_now(self): self.assertTrue("7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6" in job_response_content) self.assertTrue("RefreshExtract" in job_response_content) + + def test_create_extract_task(self): + monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), interval_value=15) + monthly_schedule = TSC.ScheduleItem( + None, + None, + None, + None, + monthly_interval, + ) + target_item = TSC.Target("workbook_id", "workbook") + + task = TaskItem(None, "FullRefresh", None, schedule_item=monthly_schedule, target=target_item) + + 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) + create_response_content = self.server.tasks.create(task).decode("utf-8") + + self.assertTrue("task_id" in create_response_content) + self.assertTrue("workbook_id" in create_response_content) + self.assertTrue("FullRefresh" in create_response_content) From 9afc0b30dd08dcebab7ef9a38d291aa46e5c0d30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20W=C5=82odarczyk?= Date: Thu, 21 Sep 2023 22:13:41 +0200 Subject: [PATCH 17/23] Added Filtering Capability for Tableau Download View Crosstab Excel (#1281) add missing filters for crosstab --- tableauserverclient/server/request_options.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 1ee18e9df..796f8add3 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -167,6 +167,7 @@ def get_query_params(self): if self.max_age != -1: params["maxAge"] = self.max_age + self._append_view_filters(params) return params From 81af54ac7360d8bc929cf54f069d7c75320b4435 Mon Sep 17 00:00:00 2001 From: jorwoods Date: Thu, 21 Sep 2023 15:20:28 -0500 Subject: [PATCH 18/23] Enable asJob for group update (#1276) --- .../server/endpoint/groups_endpoint.py | 5 ++++- test/assets/group_update_async.xml | 10 ++++++++++ test/test_group.py | 19 ++++++++++++++++++- 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 test/assets/group_update_async.xml diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index ad3828568..ab5f672d1 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -82,14 +82,17 @@ def update( ) group_item.minimum_site_role = default_site_role + url = "{0}/{1}".format(self.baseurl, group_item.id) + if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) if as_job and (group_item.domain_name is None or group_item.domain_name == "local"): error = "Local groups cannot be updated asynchronously." raise ValueError(error) + elif as_job: + url = "?".join([url, "asJob=True"]) - url = "{0}/{1}".format(self.baseurl, group_item.id) update_req = RequestFactory.Group.update_req(group_item, None) server_response = self.put_request(url, update_req) logger.info("Updated group item (ID: {0})".format(group_item.id)) diff --git a/test/assets/group_update_async.xml b/test/assets/group_update_async.xml new file mode 100644 index 000000000..ea6b47eaa --- /dev/null +++ b/test/assets/group_update_async.xml @@ -0,0 +1,10 @@ + + + + diff --git a/test/test_group.py b/test/test_group.py index 306d42170..1edc50555 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -1,11 +1,14 @@ # encoding=utf-8 +from pathlib import Path import unittest import os import requests_mock import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +TEST_ASSET_DIR = Path(__file__).absolute().parent / "assets" + +# TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = os.path.join(TEST_ASSET_DIR, "group_get.xml") POPULATE_USERS = os.path.join(TEST_ASSET_DIR, "group_populate_users.xml") @@ -16,6 +19,7 @@ CREATE_GROUP_AD = os.path.join(TEST_ASSET_DIR, "group_create_ad.xml") CREATE_GROUP_ASYNC = os.path.join(TEST_ASSET_DIR, "group_create_async.xml") UPDATE_XML = os.path.join(TEST_ASSET_DIR, "group_update.xml") +UPDATE_ASYNC_XML = TEST_ASSET_DIR / "group_update_async.xml" class GroupTests(unittest.TestCase): @@ -245,3 +249,16 @@ def test_update_local_async(self) -> None: # mimic group returned from server where domain name is set to 'local' group.domain_name = "local" self.assertRaises(ValueError, self.server.groups.update, group, as_job=True) + + def test_update_ad_async(self) -> None: + group = TSC.GroupItem("myGroup", "example.com") + group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" + group.minimum_site_role = TSC.UserItem.Roles.Viewer + + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{group.id}?asJob=True", text=UPDATE_ASYNC_XML.read_bytes().decode("utf8")) + job = self.server.groups.update(group, as_job=True) + + self.assertEqual(job.id, "c2566efc-0767-4f15-89cb-56acb4349c1b") + self.assertEqual(job.mode, "Asynchronous") + self.assertEqual(job.type, "GroupSync") From 3a49700d00db9c8c450e4248c08696c66d933f82 Mon Sep 17 00:00:00 2001 From: jorwoods Date: Thu, 21 Sep 2023 21:12:32 -0500 Subject: [PATCH 19/23] Fix shared attribute for custom views (#1280) --- tableauserverclient/models/custom_view_item.py | 5 +++++ test/test_custom_view.py | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py index e0b47c738..246a19e7f 100644 --- a/tableauserverclient/models/custom_view_item.py +++ b/tableauserverclient/models/custom_view_item.py @@ -134,6 +134,7 @@ def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["CustomVi cv_item._content_url = custom_view_xml.get("contentUrl", None) cv_item._id = custom_view_xml.get("id", None) cv_item._name = custom_view_xml.get("name", None) + cv_item._shared = string_to_bool(custom_view_xml.get("shared", None)) if owner_elem is not None: parsed_owners = UserItem.from_response_as_owner(tostring(custom_view_xml), ns) @@ -154,3 +155,7 @@ def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["CustomVi all_view_items.append(cv_item) return all_view_items + + +def string_to_bool(s: Optional[str]) -> bool: + return (s or "").lower() == "true" diff --git a/test/test_custom_view.py b/test/test_custom_view.py index c1fe8c407..55dec5df1 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -41,14 +41,15 @@ def test_get(self) -> None: self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[0].owner.id) self.assertIsNone(all_views[0].created_at) self.assertIsNone(all_views[0].updated_at) + self.assertFalse(all_views[0].shared) self.assertEqual("fd252f73-593c-4c4e-8584-c032b8022adc", all_views[1].id) self.assertEqual("Overview", all_views[1].name) - self.assertEqual(False, all_views[1].shared) self.assertEqual("6d13b0ca-043d-4d42-8c9d-3f3313ea3a00", all_views[1].workbook.id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[1].owner.id) self.assertEqual("2002-05-30T09:00:00Z", format_datetime(all_views[1].created_at)) self.assertEqual("2002-06-05T08:00:59Z", format_datetime(all_views[1].updated_at)) + self.assertTrue(all_views[1].shared) def test_get_by_id(self) -> None: with open(GET_XML_ID, "rb") as f: From f94f72d7dd82cdcff11e72037f699fe376684505 Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 18 Apr 2023 19:59:35 -0700 Subject: [PATCH 20/23] run long requests on second thread (#1212) * run long requests on second thread * improve chunked upload requests * begin extracting constants for user editing * centrally configured logger --- .../server/endpoint/favorites_endpoint.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index ac9e4b185..f6ab7d4b6 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,4 +1,5 @@ from .endpoint import Endpoint, api +<<<<<<< HEAD from requests import Response from tableauserverclient.helpers.logging import logger @@ -16,6 +17,18 @@ ) from tableauserverclient.server import RequestFactory, RequestOptions from typing import Optional +======= +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import FavoriteItem + +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ...models import DatasourceItem, FlowItem, ProjectItem, UserItem, ViewItem, WorkbookItem + from ..request_options import RequestOptions + +from tableauserverclient.helpers.logging import logger +>>>>>>> 3cc28be (run long requests on second thread (#1212)) class Favorites(Endpoint): From c812e4bd24fac32bae4c3060fd805fae907b38a7 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Mon, 24 Apr 2023 12:10:37 -0700 Subject: [PATCH 21/23] fix imports --- .../server/endpoint/favorites_endpoint.py | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index f6ab7d4b6..ee2ba9041 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,23 +1,4 @@ from .endpoint import Endpoint, api -<<<<<<< HEAD -from requests import Response - -from tableauserverclient.helpers.logging import logger -from tableauserverclient.models import ( - DatasourceItem, - FavoriteItem, - FlowItem, - MetricItem, - ProjectItem, - Resource, - TableauItem, - UserItem, - ViewItem, - WorkbookItem, -) -from tableauserverclient.server import RequestFactory, RequestOptions -from typing import Optional -======= from tableauserverclient.server import RequestFactory from tableauserverclient.models import FavoriteItem @@ -28,7 +9,6 @@ from ..request_options import RequestOptions from tableauserverclient.helpers.logging import logger ->>>>>>> 3cc28be (run long requests on second thread (#1212)) class Favorites(Endpoint): From 9f2e870ff9bbe8408494b80037fff4cd06ffe349 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 22 Sep 2023 04:36:55 +0000 Subject: [PATCH 22/23] Sep 21, 2023, 9:36 PM --- tableauserverclient/server/endpoint/favorites_endpoint.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index ee2ba9041..7e32a9d32 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,12 +1,10 @@ from .endpoint import Endpoint, api from tableauserverclient.server import RequestFactory -from tableauserverclient.models import FavoriteItem +from tableauserverclient.models import FavoriteItem, UserItem, Resource from typing import Optional, TYPE_CHECKING - -if TYPE_CHECKING: - from ...models import DatasourceItem, FlowItem, ProjectItem, UserItem, ViewItem, WorkbookItem - from ..request_options import RequestOptions +from ..request_options import RequestOptions +from ...models import DatasourceItem, FlowItem, ProjectItem, ViewItem, WorkbookItem, TableauItem, MetricItem from tableauserverclient.helpers.logging import logger From c1e17ce9b109b1ece098f86c721e58a9eb1e6f98 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 22 Sep 2023 05:42:45 +0000 Subject: [PATCH 23/23] Sep 21, 2023, 10:42 PM --- .../server/endpoint/favorites_endpoint.py | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 7e32a9d32..f82b1b3d5 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,12 +1,20 @@ from .endpoint import Endpoint, api -from tableauserverclient.server import RequestFactory -from tableauserverclient.models import FavoriteItem, UserItem, Resource - -from typing import Optional, TYPE_CHECKING -from ..request_options import RequestOptions -from ...models import DatasourceItem, FlowItem, ProjectItem, ViewItem, WorkbookItem, TableauItem, MetricItem - +from requests import Response from tableauserverclient.helpers.logging import logger +from tableauserverclient.models import ( + DatasourceItem, + FavoriteItem, + FlowItem, + MetricItem, + ProjectItem, + Resource, + TableauItem, + UserItem, + ViewItem, + WorkbookItem, +) +from tableauserverclient.server import RequestFactory, RequestOptions +from typing import Optional class Favorites(Endpoint):