From 0fe5e961ebd97a4203eee5abcfb506533a2452a1 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 30 Jul 2026 15:29:40 -0700 Subject: [PATCH 01/15] samples: add shared credential resolver that avoids the command line Introduces samples/_shared.py with resolve_credentials(args), which fills missing sign-in values from env vars (TABLEAU_SERVER, TABLEAU_TOKEN_NAME, etc.) or a .env-style file, and falls back to interactive getpass so secrets never touch shell history. CLI args still work for CI use. Wires the new helper into login.py, publish_workbook.py, and publish_datasource.py to establish the pattern; the remaining samples still accept the same CLI args and continue to work as before. Addresses tableau/server-client-python#1551 item 1. --- samples/_shared.py | 201 ++++++++++++++++++++++++++++++++++ samples/login.py | 73 +++--------- samples/publish_datasource.py | 42 ++----- samples/publish_workbook.py | 21 ++-- 4 files changed, 233 insertions(+), 104 deletions(-) create mode 100644 samples/_shared.py diff --git a/samples/_shared.py b/samples/_shared.py new file mode 100644 index 000000000..e728c227c --- /dev/null +++ b/samples/_shared.py @@ -0,0 +1,201 @@ +#### +# Shared helpers for the sample scripts in this directory. +# +# The most important thing here is `resolve_credentials`, which lets samples +# accept a Tableau server URL, site, and credentials from three sources: +# +# 1. Command-line arguments (useful for CI, but note that these end up in +# shell history and process listings, so avoid them for real secrets). +# 2. Environment variables. If a `.env` file exists next to the sample +# being run, or in the current working directory, we load it first -- +# only the standard `KEY=value` lines, no external dependency required. +# 3. Interactive prompts. Missing values are asked for on stdin; secrets +# are read with `getpass.getpass` so they are not echoed. +# +# CLI args take precedence, then environment, then interactive prompt. +# This lets a user set defaults in a `.env` file and override individual +# values on the command line. +#### + +from __future__ import annotations + +import argparse +import getpass +import os +from pathlib import Path +from typing import Iterable + +import tableauserverclient as TSC + +# Recognized environment variable names, in the order we look them up. +# Older samples used TABLEAU_SERVER etc; keep those working as aliases. +_ENV_ALIASES: dict[str, tuple[str, ...]] = { + "server": ("TABLEAU_SERVER", "SERVER"), + "site": ("TABLEAU_SITE", "SITE"), + "token_name": ("TABLEAU_TOKEN_NAME", "TOKEN_NAME"), + "token_value": ("TABLEAU_TOKEN_VALUE", "TOKEN_VALUE"), + "username": ("TABLEAU_USERNAME", "USERNAME"), + "password": ("TABLEAU_PASSWORD", "PASSWORD"), +} + + +def add_common_arguments(parser: argparse.ArgumentParser) -> None: + """Add the sign-in and logging arguments used by every sample. + + Kept in sync with the historical inline definitions so no existing + command line breaks. All arguments are optional -- missing values + are pulled from the environment or prompted for interactively. + """ + parser.add_argument("--server", "-s", help="server address (env: TABLEAU_SERVER)") + parser.add_argument("--site", "-S", help="site content URL (env: TABLEAU_SITE)") + parser.add_argument( + "--token-name", + "-p", + help="name of the personal access token used to sign into the server " "(env: TABLEAU_TOKEN_NAME)", + ) + parser.add_argument( + "--token-value", + "-v", + help="value of the personal access token used to sign into the server " + "(env: TABLEAU_TOKEN_VALUE). Prefer the env var or interactive prompt over the " + "command line so the secret does not land in shell history.", + ) + parser.add_argument( + "--username", + help="username to sign into the server (env: TABLEAU_USERNAME). Only used if " + "no personal access token is supplied.", + ) + parser.add_argument( + "--password", + help="password (env: TABLEAU_PASSWORD). Prefer the env var or interactive " "prompt over the command line.", + ) + parser.add_argument( + "--env-file", + help="path to a .env-style file with KEY=value lines to load. If omitted, " + ".env in the current directory is loaded automatically when present.", + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) + + +def _load_env_file(path: Path) -> None: + """Very small `.env` loader: `KEY=value` per line, `#` for comments. + + We do not want a runtime dependency on python-dotenv for the samples, + so this parses just the common cases. Existing env vars are not + overwritten -- a value already in `os.environ` wins. + """ + try: + text = path.read_text(encoding="utf-8") + except OSError: + return + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip("'\"") + if key and key not in os.environ: + os.environ[key] = value + + +def _first_env(names: Iterable[str]) -> str | None: + for name in names: + val = os.environ.get(name) + if val: + return val + return None + + +def resolve_credentials(args: argparse.Namespace, *, allow_prompt: bool = True) -> None: + """Fill in server/site/credential values on `args` from env or prompt. + + Precedence for each field: existing value on `args` > environment variable + > interactive prompt (if allow_prompt and stdin is a terminal). + + Pass `allow_prompt=False` in CI environments where blocking on input would + hang the job; the caller should then verify the fields it needs are set. + """ + # Load `.env` file if one is requested or available. + env_file = getattr(args, "env_file", None) + if env_file: + _load_env_file(Path(env_file)) + else: + default_env = Path.cwd() / ".env" + if default_env.is_file(): + _load_env_file(default_env) + + # For each field, prefer the CLI arg, then env, then prompt. + for field, env_names in _ENV_ALIASES.items(): + current = getattr(args, field, None) + if current: + continue + env_val = _first_env(env_names) + if env_val: + setattr(args, field, env_val) + + if not allow_prompt: + return + + # Prompt for what's still missing. We only prompt for the pieces we + # actually need: server URL, and one of token or username/password. + if not getattr(args, "server", None): + args.server = input("Tableau server URL: ").strip() + + # Site is optional (empty string is the default site) so we don't prompt. + + has_token = getattr(args, "token_name", None) and getattr(args, "token_value", None) + has_user = getattr(args, "username", None) and getattr(args, "password", None) + + if has_token or has_user: + return + + # Nothing configured yet. Ask which auth method to use. + if getattr(args, "token_name", None) or getattr(args, "username", None): + # Partial info supplied -- fill in the matching missing piece. + if getattr(args, "token_name", None) and not getattr(args, "token_value", None): + args.token_value = getpass.getpass(f"Personal access token value for '{args.token_name}': ") + return + if getattr(args, "username", None) and not getattr(args, "password", None): + args.password = getpass.getpass(f"Password for '{args.username}': ") + return + + # Fully unspecified: default to PAT since that's what the docs recommend. + print("No credentials found in args or environment. Sign in with a personal access token.") + print("(Set TABLEAU_TOKEN_NAME / TABLEAU_TOKEN_VALUE in your env or a .env file to skip this prompt.)") + args.token_name = input("Personal access token name: ").strip() + args.token_value = getpass.getpass("Personal access token value: ") + + +def build_auth(args: argparse.Namespace) -> TSC.TableauAuth | TSC.PersonalAccessTokenAuth: + """Return the appropriate auth object based on what's set on `args`.""" + site = getattr(args, "site", None) or "" + if getattr(args, "token_name", None) and getattr(args, "token_value", None): + return TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=site) + if getattr(args, "username", None) and getattr(args, "password", None): + return TSC.TableauAuth(args.username, args.password, site_id=site) + raise ValueError( + "No usable credentials found. Provide --token-name/--token-value, " + "--username/--password, or set the corresponding env vars." + ) + + +def sign_in(args: argparse.Namespace, *, use_server_version: bool = True) -> TSC.Server: + """Convenience helper: resolve credentials, build the server, and sign in. + + The caller is responsible for calling `server.auth.sign_out()` or using + the `with server.auth.sign_in(...)` context manager pattern themselves + when they need finer control. This helper is intended for the small + samples that just want a signed-in server object to poke at. + """ + resolve_credentials(args) + auth = build_auth(args) + server = TSC.Server(args.server, use_server_version=use_server_version) + server.auth.sign_in(auth) + return server diff --git a/samples/login.py b/samples/login.py index bc99385b3..fb339e4e5 100644 --- a/samples/login.py +++ b/samples/login.py @@ -2,82 +2,43 @@ # This script demonstrates how to log in to Tableau Server Client. # # To run the script, you must have installed Python 3.7 or later. +# +# Credentials can be supplied on the command line, from environment variables +# (TABLEAU_SERVER, TABLEAU_SITE, TABLEAU_TOKEN_NAME, TABLEAU_TOKEN_VALUE, +# TABLEAU_USERNAME, TABLEAU_PASSWORD), from a `.env` file in the current +# working directory, or interactively via getpass. Prefer env or a .env file +# over CLI args so secrets do not end up in your shell history. #### import argparse -import getpass import logging -import os import tableauserverclient as TSC - -def get_env(key): - if key in os.environ: - return os.environ[key] - return None +from _shared import add_common_arguments, build_auth, resolve_credentials # If a sample has additional arguments, then it should copy this code and insert them after the call to -# sample_define_common_options -# If it has no additional arguments, it can just call this method +# add_common_arguments. If it has no additional arguments, it can just call this method. def set_up_and_log_in(): parser = argparse.ArgumentParser(description="Logs in to the server.") - sample_define_common_options(parser) + add_common_arguments(parser) args = parser.parse_args() - if not args.server: - args.server = get_env("SERVER") - if not args.site: - args.site = get_env("SITE") - if not args.token_name: - args.token_name = get_env("TOKEN_NAME") - if not args.token_value: - args.token_value = get_env("TOKEN_VALUE") - args.logging_level = "debug" + + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) server = sample_connect_to_server(args) print(server.server_info.get()) print(server.server_address, "site:", server.site_id, "user:", server.user_id) -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", help="server address") - parser.add_argument("--site", "-t", help="site name") - 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") - - parser.add_argument("--token-value", "-tv", help="value of the personal access token used to sign into the server") - parser.add_argument("--password", "-p", help="value of the password 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)", - ) - - def sample_connect_to_server(args): - if args.username: - # Trying to authenticate using username and password. - password = args.password or getpass.getpass("Password: ") - - tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.site) - print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nUsername: {args.username}") - - else: - # Trying to authenticate using personal access tokens. - token = args.token_value or getpass.getpass("Personal Access Token: ") - - tableau_auth = TSC.PersonalAccessTokenAuth( - token_name=args.token_name, personal_access_token=token, site_id=args.site - ) + tableau_auth = build_auth(args) + if isinstance(tableau_auth, TSC.PersonalAccessTokenAuth): print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nToken name: {args.token_name}") - - if not tableau_auth: - raise TabError("Did not create authentication object. Check arguments.") + else: + print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nUsername: {args.username}") # Only set this to False if you are running against a server you trust AND you know why the cert is broken check_ssl_certificate = True @@ -85,8 +46,6 @@ def sample_connect_to_server(args): # Make sure we use an updated version of the rest apis, and pass in our cert handling choice server = TSC.Server(args.server, use_server_version=True, http_options={"verify": check_ssl_certificate}) server.auth.sign_in(tableau_auth) - server.version = "3.19" - return server diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index c674e6882..772c436f1 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -21,31 +21,17 @@ import argparse import logging -import os import tableauserverclient as TSC import tableauserverclient.datetime_helpers - -def get_env(key): - if key in os.environ: - return os.environ[key] - return None +from _shared import add_common_arguments, build_auth, resolve_credentials 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", 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)", - ) + # Common options -- credentials come from CLI args, env vars, a .env file, + # or an interactive prompt. See samples/_shared.py. + add_common_arguments(parser) # Options specific to this sample parser.add_argument("--file", "-f", help="filepath to the datasource to publish") parser.add_argument("--project", help="Project within which to publish the datasource") @@ -56,30 +42,18 @@ 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 = get_env("SERVER") - if not args.site: - args.site = get_env("SITE") - if not args.token_name: - args.token_name = get_env("TOKEN_NAME") - if not args.token_value: - args.token_value = get_env("TOKEN_VALUE") - args.logging = "debug" - args.file = "C:/dev/tab-samples/5M.tdsx" - args.async_ = True + + resolve_credentials(args) # 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 - - _logger = logging.getLogger(__name__) - _logger.setLevel(logging.DEBUG) - _logger.addHandler(logging.StreamHandler()) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) # Sign in to server - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + tableau_auth = build_auth(args) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): # Empty project_id field will default the publish to the site's default project diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 077ddaddd..5efd18e54 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -20,21 +20,14 @@ import tableauserverclient as TSC from tableauserverclient import ConnectionCredentials, ConnectionItem +from _shared import add_common_arguments, build_auth, resolve_credentials + 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", 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)", - ) + # Common options -- credentials come from CLI args, env vars, a .env file, + # or an interactive prompt. See samples/_shared.py. + add_common_arguments(parser) # Options specific to this sample group = parser.add_mutually_exclusive_group(required=False) group.add_argument("--thumbnails-user-id", "-u", help="User ID to use for thumbnails") @@ -49,12 +42,14 @@ def main(): args = parser.parse_args() + resolve_credentials(args) + # Set logging level based on user input, or error by default logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) # Step 1: Sign in to server. - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + tableau_auth = build_auth(args) server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False}) with server.auth.sign_in(tableau_auth): # Step2: Retrieve the project id, if a project name was passed From 97ca972db74e8595c4ec715f44fbbdffdb919c26 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 30 Jul 2026 15:31:54 -0700 Subject: [PATCH 02/15] samples: fix mispagination in samples that treated a single page as all Several samples called `server..get()` and named the result `all_workbooks`, `all_datasources`, etc. This only returns the first page (default 100 items); if the item of interest was not on that page it was silently missed and the sample failed with a "not found" message. Replace those calls with `TSC.Pager(server.)` so every page is walked. Where a total count was being displayed we still make one plain `.get()` up front so the total_available field is available without paging through the whole site twice. Also corrects an unrelated typo in getting_started/3_hello_universe.py where the "workbooks" section actually queried datasources. Addresses tableau/server-client-python#1551 item 2 (and #1531). --- samples/explore_datasource.py | 14 +++++++++----- samples/explore_favorites.py | 7 ++++--- samples/explore_webhooks.py | 6 ++++-- samples/explore_workbook.py | 17 ++++++++++------- samples/extracts.py | 6 ++++-- samples/getting_started/3_hello_universe.py | 2 +- samples/move_workbook_sites.py | 6 +++--- samples/publish_workbook.py | 7 +++++-- samples/refresh_tasks.py | 4 ++-- .../update_workbook_data_freshness_policy.py | 6 ++++-- 10 files changed, 46 insertions(+), 29 deletions(-) diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index c9f35d5be..9938aaf09 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -43,9 +43,10 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - # Query projects for use when demonstrating publishing and updating - all_projects, pagination_item = server.projects.get() - default_project = next((project for project in all_projects if project.is_default()), None) + # Query projects for use when demonstrating publishing and updating. + # Use TSC.Pager (or `.all()` / `.filter()`) to iterate every page; + # a raw `server.projects.get()` only returns the first page. + default_project = next((project for project in TSC.Pager(server.projects) if project.is_default()), None) # Publish datasource if publish flag is set (-publish, -p) if args.publish: @@ -59,9 +60,12 @@ def main(): else: print("Publish failed. Could not find the default project.") - # Gets all datasource items - all_datasources, pagination_item = server.datasources.get() + # Gets all datasource items. `.get()` returns only one page; use + # TSC.Pager to iterate every page. The first response also gives us + # the total_available count without paging through everything. + first_page, pagination_item = server.datasources.get() print(f"\nThere are {pagination_item.total_available} datasources on site: ") + all_datasources = list(TSC.Pager(server.datasources)) print([datasource.name for datasource in all_datasources]) if all_datasources: diff --git a/samples/explore_favorites.py b/samples/explore_favorites.py index f199522ed..8358f8d6e 100644 --- a/samples/explore_favorites.py +++ b/samples/explore_favorites.py @@ -43,8 +43,9 @@ def main(): server.favorites.get(user) print(user.favorites) - # get list of workbooks - all_workbook_items, pagination_item = server.workbooks.get() + # get list of workbooks. `.get()` only returns one page; use + # TSC.Pager to iterate every workbook on the site. + all_workbook_items = list(TSC.Pager(server.workbooks)) if all_workbook_items is not None and len(all_workbook_items) > 0: my_workbook = all_workbook_items[0] server.favorites.add_favorite(user, Resource.Workbook, all_workbook_items[0]) @@ -59,7 +60,7 @@ def main(): server.favorites.add_favorite_view(user, my_view) print(f"View added to favorites. View Name: {my_view.name}, View ID: {my_view.id}") - all_datasource_items, pagination_item = server.datasources.get() + all_datasource_items = list(TSC.Pager(server.datasources)) if all_datasource_items: my_datasource = all_datasource_items[0] server.favorites.add_favorite_datasource(user, my_datasource) diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index f25c41849..129cc6bbf 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -54,9 +54,11 @@ def main(): new_webhook = server.webhooks.create(new_webhook) print(f"Webhook created. ID: {new_webhook.id}") - # Gets all webhook items - all_webhooks, pagination_item = server.webhooks.get() + # Gets all webhook items. `.get()` returns only one page; use + # TSC.Pager to iterate every webhook on the site. + first_page, pagination_item = server.webhooks.get() print(f"\nThere are {pagination_item.total_available} webhooks on site: ") + all_webhooks = list(TSC.Pager(server.webhooks)) print([webhook.name for webhook in all_webhooks]) if all_webhooks: diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index d537f21d6..10af0902d 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -53,8 +53,9 @@ def main(): # Publish workbook if publish flag is set (-publish, -p) overwrite_true = TSC.Server.PublishMode.Overwrite if args.publish: - all_projects, pagination_item = server.projects.get() - default_project = next((project for project in all_projects if project.is_default()), None) + # Use TSC.Pager rather than a raw `.get()` because `.get()` only + # returns the first page of results. + default_project = next((project for project in TSC.Pager(server.projects) if project.is_default()), None) if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) @@ -63,9 +64,11 @@ def main(): else: print("Publish failed. Could not find the default project.") - # Gets all workbook items - all_workbooks, pagination_item = server.workbooks.get() + # Gets all workbook items. Note that `.get()` only returns the first + # page of results; use TSC.Pager to iterate every page. + first_page, pagination_item = server.workbooks.get() print(f"\nThere are {pagination_item.total_available} workbooks on site: ") + all_workbooks = list(TSC.Pager(server.workbooks)) print([workbook.name for workbook in all_workbooks]) if all_workbooks: @@ -123,9 +126,9 @@ def main(): f.write(sample_workbook.preview_image) print(f"\nDownloaded preview image of workbook to {os.path.abspath(args.preview_image)}") - # get custom views - cvs, _ = server.custom_views.get() - for c in cvs: + # Get custom views. `.get()` only returns the first page; + # use TSC.Pager to iterate every custom view on the site. + for c in TSC.Pager(server.custom_views): print(c) # for the last custom view in the list diff --git a/samples/extracts.py b/samples/extracts.py index d9289452a..1518adb01 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -53,9 +53,11 @@ def main(): if ds is None: raise ValueError(f"Datasource not found for id {args.datasource}") else: - # Gets all workbook items - all_workbooks, pagination_item = server.workbooks.get() + # Gets all workbook items. `.get()` returns only the first page, + # so we use TSC.Pager to iterate every page. + first_page, pagination_item = server.workbooks.get() print(f"\nThere are {pagination_item.total_available} workbooks on site: ") + all_workbooks = list(TSC.Pager(server.workbooks)) print([workbook.name for workbook in all_workbooks]) if all_workbooks: diff --git a/samples/getting_started/3_hello_universe.py b/samples/getting_started/3_hello_universe.py index a2c4301d0..057298b4d 100644 --- a/samples/getting_started/3_hello_universe.py +++ b/samples/getting_started/3_hello_universe.py @@ -40,7 +40,7 @@ def main(): for project in projects: print(project.name) - workbooks, pagination = server.datasources.get() + workbooks, pagination = server.workbooks.get() if workbooks: print(f"{pagination.total_available} workbooks") print(workbooks[0]) diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index e82c75cf9..39cc4ba93 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -65,10 +65,10 @@ def main(): try: workbook_path = source_server.workbooks.download(all_workbooks[0].id, tmpdir) - # Step 4: Check if destination site exists, then sign in to the site - all_sites, pagination_info = source_server.sites.get() + # Step 4: Check if destination site exists, then sign in to the site. + # Use TSC.Pager because `.get()` only returns the first page of sites. found_destination_site = any( - True for site in all_sites if args.destination_site.lower() == site.content_url.lower() + args.destination_site.lower() == site.content_url.lower() for site in TSC.Pager(source_server.sites) ) if not found_destination_site: error = f"No site named {args.destination_site} found." diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 5efd18e54..7d3bc6b51 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -64,8 +64,11 @@ def main(): project_id = projects[0].id else: # Get all the projects on server, then look for the default one. - all_projects, pagination_item = server.projects.get() - project_id = next((project for project in all_projects if project.is_default()), None).id + # Use TSC.Pager because `.get()` only returns the first page. + default_project = next((project for project in TSC.Pager(server.projects) if project.is_default()), None) + if default_project is None: + raise LookupError("The destination project could not be found.") + project_id = default_project.id connection1 = ConnectionItem() connection1.server_address = "mssql.test.com" diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index c95000898..16effb004 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -17,8 +17,8 @@ def handle_run(server, args): def handle_list(server, _): - tasks, pagination = server.tasks.get() - for task in tasks: + # Use TSC.Pager to iterate every task; `.get()` returns only the first page. + for task in TSC.Pager(server.tasks): print(f"{task}") diff --git a/samples/update_workbook_data_freshness_policy.py b/samples/update_workbook_data_freshness_policy.py index c23e3717f..9eee2fe75 100644 --- a/samples/update_workbook_data_freshness_policy.py +++ b/samples/update_workbook_data_freshness_policy.py @@ -43,9 +43,11 @@ def main(): server.add_http_options({"verify": False}) server.use_server_version() with server.auth.sign_in(tableau_auth): - # Get workbook - all_workbooks, pagination_item = server.workbooks.get() + # Get workbooks. `.get()` only returns the first page; iterate with + # TSC.Pager to see every workbook on the site. + first_page, pagination_item = server.workbooks.get() print(f"\nThere are {pagination_item.total_available} workbooks on site: ") + all_workbooks = list(TSC.Pager(server.workbooks)) print([workbook.name for workbook in all_workbooks]) if all_workbooks: From 6b369de94710417981995740a05c89fd7a117e11 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 30 Jul 2026 15:34:42 -0700 Subject: [PATCH 03/15] samples: add list_jobs and manage_subscriptions for coverage gaps The existing samples cover workbooks, datasources, schedules, extracts, projects, users, groups, favorites, and webhooks, but there was no sample for two frequently asked-about endpoints: * list_jobs.py -- lists background jobs (extract refreshes, publishes, flow runs, etc.), demonstrating the .filter() queryset with date/status/type filters and the wait_for_job helper. * manage_subscriptions.py -- list/create/delete site subscriptions, demonstrating the SubscriptionItem + Target pattern and paginated listing with TSC.Pager. Both samples use the new samples/_shared.py credential resolver so the sign-in pattern matches the rest of the samples. Addresses tableau/server-client-python#1551 item 3. --- samples/list_jobs.py | 134 ++++++++++++++++++++++++++++++++ samples/manage_subscriptions.py | 121 ++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 samples/list_jobs.py create mode 100644 samples/manage_subscriptions.py diff --git a/samples/list_jobs.py b/samples/list_jobs.py new file mode 100644 index 000000000..e1835abf4 --- /dev/null +++ b/samples/list_jobs.py @@ -0,0 +1,134 @@ +#### +# This script demonstrates how to list background jobs on a Tableau site +# and (optionally) wait for a specific job to finish. +# +# Background jobs are created when you run an extract refresh, publish +# asynchronously, run a flow, delete a site asynchronously, and so on. +# See the REST API "Query Jobs" reference for the full list of job types. +# +# Examples: +# +# # List every job on the site, most recent first. +# python samples/list_jobs.py +# +# # Only jobs from the last 24 hours. +# python samples/list_jobs.py --hours 24 +# +# # Only in-progress refresh_extracts jobs. +# python samples/list_jobs.py --status InProgress --type refresh_extracts +# +# # Wait for a specific job to finish. +# python samples/list_jobs.py --wait +# +# To run the script, you must have installed Python 3.9 or later. +#### + +import argparse +import datetime +import logging + +import tableauserverclient as TSC +from tableauserverclient.server.endpoint.exceptions import JobCancelledException, JobFailedException + +from _shared import add_common_arguments, build_auth, resolve_credentials + + +def main(): + parser = argparse.ArgumentParser(description="List background jobs on the site, or wait for one to finish.") + add_common_arguments(parser) + + parser.add_argument( + "--hours", + type=int, + help="Only show jobs created in the last N hours (uses the filter endpoint).", + ) + parser.add_argument( + "--status", + help="Filter by job status, e.g. Success, Failed, InProgress, Cancelled, Pending.", + ) + parser.add_argument( + "--type", + dest="job_type", + help="Filter by job type, e.g. refresh_extracts, publish, run_flow.", + ) + parser.add_argument( + "--wait", + metavar="JOB_ID", + help="Instead of listing, wait for the given job ID to complete and print the result.", + ) + parser.add_argument( + "--timeout", + type=float, + help="Max seconds to wait when --wait is used. Defaults to no timeout.", + ) + + args = parser.parse_args() + + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) + + tableau_auth = build_auth(args) + server = TSC.Server(args.server, use_server_version=True) + + with server.auth.sign_in(tableau_auth): + if args.wait: + _wait_for_job(server, args.wait, args.timeout) + return + + _list_jobs(server, args) + + +def _list_jobs(server, args): + """List jobs using the queryset filter API, which handles pagination for us.""" + + # `server.jobs.filter(...)` returns a QuerySet that is directly iterable + # and pages through the server automatically. This is the recommended + # way to iterate every job on the site -- do NOT use a raw + # `server.jobs.get()`, which only returns the first page. + query = server.jobs.filter() + + if args.hours is not None: + cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=args.hours) + # Filter operator suffixes: __gt / __gte / __lt / __lte / __in / __has + # See tableauserverclient.server.query.QuerySet for the full list. + query = query.filter(created_at__gte=cutoff.isoformat()) + + if args.status: + query = query.filter(status=args.status) + + if args.job_type: + query = query.filter(job_type=args.job_type) + + # Newest first is usually what a human wants when scanning. + query = query.order_by("-created_at") + + printed = 0 + for job in query: + # BackgroundJobItem fields: id, type, status, created_at, started_at, ended_at, ... + print( + f"{job.id} {job.type or '-':<24} {job.status or '-':<12} " + f"created={job.created_at} ended={job.ended_at}" + ) + printed += 1 + + if printed == 0: + print("No jobs matched the given filters.") + + +def _wait_for_job(server, job_id, timeout): + """Poll a single job until it finishes, using the built-in helper.""" + try: + job = server.jobs.wait_for_job(job_id, timeout=timeout) + except JobFailedException as exc: + # The exception carries the failed JobItem so callers can inspect it. + print(f"Job {job_id} failed: notes={exc.job.notes}") + raise SystemExit(1) from exc + except JobCancelledException: + print(f"Job {job_id} was cancelled.") + raise SystemExit(2) + + print(f"Job {job_id} finished. finish_code={job.finish_code} notes={job.notes}") + + +if __name__ == "__main__": + main() diff --git a/samples/manage_subscriptions.py b/samples/manage_subscriptions.py new file mode 100644 index 000000000..5d8bc70e8 --- /dev/null +++ b/samples/manage_subscriptions.py @@ -0,0 +1,121 @@ +#### +# This script demonstrates how to list, create, and delete subscriptions +# on a Tableau site. +# +# A subscription pairs a user, a schedule, and a target (workbook or view); +# the user is emailed a snapshot of the target on each schedule tick. +# See the REST API "Subscriptions" reference for full details. +# +# Examples: +# +# # List every subscription on the site. +# python samples/manage_subscriptions.py list +# +# # Create a subscription for the signed-in user against a view + schedule. +# python samples/manage_subscriptions.py create \ +# --target-type view \ +# --target-id \ +# --schedule-id \ +# --subject "Daily sales snapshot" +# +# # Delete an existing subscription. +# python samples/manage_subscriptions.py delete --id +# +# To run the script, you must have installed Python 3.9 or later. +#### + +import argparse +import logging + +import tableauserverclient as TSC + +from _shared import add_common_arguments, build_auth, resolve_credentials + + +def handle_list(server, args): + """List every subscription on the site, iterating every page.""" + # `server.subscriptions.get()` returns only the first page. Pass the + # endpoint to TSC.Pager to iterate every subscription without hand- + # rolling pagination logic. + count = 0 + for sub in TSC.Pager(server.subscriptions): + print( + f"{sub.id} subject={sub.subject!r} " + f"user_id={sub.user_id} schedule_id={sub.schedule_id} target={sub.target}" + ) + count += 1 + if count == 0: + print("No subscriptions found on this site.") + + +def handle_create(server, args): + """Create a new subscription for the signed-in user (unless --user-id given).""" + user_id = args.user_id or server.user_id + if not user_id: + raise SystemExit("Could not determine user_id. Pass --user-id or ensure sign-in succeeded.") + + # The REST API expects lowercase content types ("workbook" or "view"). + target = TSC.Target(args.target_id, args.target_type.lower()) + new_sub = TSC.SubscriptionItem( + subject=args.subject, + schedule_id=args.schedule_id, + user_id=user_id, + target=target, + ) + if args.message: + new_sub.message = args.message + new_sub.attach_image = args.attach_image + new_sub.attach_pdf = args.attach_pdf + + created = server.subscriptions.create(new_sub) + print(f"Created subscription {created.id} for user {created.user_id} against {created.target}") + + +def handle_delete(server, args): + """Delete a subscription by ID.""" + server.subscriptions.delete(args.id) + print(f"Deleted subscription {args.id}.") + + +def main(): + parser = argparse.ArgumentParser(description="List, create, and delete Tableau subscriptions.") + add_common_arguments(parser) + + subcommands = parser.add_subparsers(dest="command", required=True) + + list_p = subcommands.add_parser("list", help="List every subscription on the site.") + list_p.set_defaults(func=handle_list) + + create_p = subcommands.add_parser("create", help="Create a new subscription.") + create_p.add_argument("--target-type", required=True, choices=["Workbook", "View", "workbook", "view"]) + create_p.add_argument("--target-id", required=True, help="ID of the workbook or view to subscribe to.") + create_p.add_argument( + "--schedule-id", required=True, help="ID of the schedule to attach to (see create_schedules.py)." + ) + create_p.add_argument("--subject", required=True, help="Email subject line.") + create_p.add_argument("--message", help="Optional email body message.") + create_p.add_argument( + "--user-id", + help="User to subscribe. Defaults to the signed-in user.", + ) + create_p.add_argument("--attach-image", action="store_true", default=True, help="Attach a PNG snapshot (default).") + create_p.add_argument("--attach-pdf", action="store_true", default=False, help="Also attach a PDF snapshot.") + create_p.set_defaults(func=handle_create) + + delete_p = subcommands.add_parser("delete", help="Delete a subscription by ID.") + delete_p.add_argument("--id", required=True, help="Subscription ID to delete.") + delete_p.set_defaults(func=handle_delete) + + args = parser.parse_args() + + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) + + tableau_auth = build_auth(args) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(tableau_auth): + args.func(server, args) + + +if __name__ == "__main__": + main() From b6d08356c23f5c78e59773a820036df3ca1ccd29 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Wed, 5 Aug 2026 19:13:24 -0700 Subject: [PATCH 04/15] Improve the stale actions config now that it's been running for a bit (#1853) - increase operations-per-run beyond the default 30 - add permission for action to write its cache Taken together, these should let the action run on the oldest issues and PRs we have. --- .github/workflows/stale.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 4fdd6e09c..576a09926 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,4 +1,4 @@ -name: Mark stale issues and pull requests +name: Stale issues and pull requests on: schedule: @@ -8,6 +8,7 @@ on: permissions: issues: write pull-requests: write + actions: write jobs: stale: @@ -25,6 +26,8 @@ jobs: days-before-pr-stale: 90 days-before-pr-close: 14 + operations-per-run: 300 + close-issue-reason: 'not_planned' stale-issue-message: > From e729e66d462658197505df047da8c643619326c8 Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 6 Aug 2026 13:20:17 -0700 Subject: [PATCH 05/15] feat: parse / into JobItem.status_notes (#1850) (#1852) The REST Query Job response schema documents a structured status-notes block: JobItem was parsing only the sibling legacy `` element (emitted by some job types like extractRefreshJob), missing the modern statusNotes entirely. For UserImport jobs and any other multi-row job where individual rows have distinct outcomes, `job.notes` came back as an empty list even when the server had sent detailed structured status. Add `JobItem.status_notes: list[dict]`, each dict with keys `type`, `value`, `text` (any of which may be None if the server omitted them). The legacy `notes: list[str]` attribute is unchanged for backwards compatibility -- it still parses the `` element still emitted by extract-refresh and similar older job types. Verified against the public REST doc: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_job The existing job_get_by_id.xml test asset already contained a statusNotes block; the get_by_id test now asserts the structured value in addition to the legacy notes list. Two new tests cover the absent case (yields []) and the multi-note case with attribute omissions. Discovered while planning tabcmd createsiteusers nowait / silent-progress work (tableau/tabcmd#35); a live probe against Tableau Server 2025.1 confirmed the server emits this schema for UserImport jobs. Fixes #1850. --- CHANGELOG.md | 7 ++++ tableauserverclient/models/job_item.py | 29 +++++++++++++++- test/test_job.py | 47 ++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 943436b27..fc1430d38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ hierarchy path (e.g. `"Marketing/Q1 Reports"`). The walk is performed level by level using the REST API name filter, so a path with *n* components issues *n* requests. Returns the matching `ProjectItem` or `None` if no project is found. +* Added `JobItem.status_notes` for the structured `` block documented on the Query + Job REST endpoint. Populated for UserImport and other multi-row jobs where + individual rows have distinct outcomes; each entry is a dict with keys + `type` / `value` / `text`. The existing `notes: list[str]` attribute is + unchanged (it parses the separate legacy `` element still emitted by + some job types). Fixes #1850. ## 0.18.0 (6 April 2022) * Switched to using defused_xml for xml attack protection diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index f684c22d4..ae6150eb5 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -49,7 +49,19 @@ class JobItem: The finish code of the job. 0 for success, 1 for failure, 2 for cancelled. notes : list[str] | None - Contains detailed notes about the job. + Detail notes emitted by legacy job types (e.g. extract refresh) inside + job-specific elements like `...`. + For modern job types see `status_notes`. + + status_notes : list[dict] | None + Structured per-row / per-metric status entries from the modern job response + schema (``). + Each element is a dict with keys `type`, `value`, `text` (any of which may + be None if the server omitted them). Populated for UserImport and other + multi-row jobs where individual rows have distinct outcomes; documented + types include `CountOfUsersAddedToSite`, `CountOfUsersSkipped`, + `CountOfUsersWithInsufficientLicenses`, etc. + See https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_job mode : str | None @@ -100,6 +112,7 @@ def __init__( updated_at: datetime.datetime | None = None, workbook_name: str | None = None, datasource_name: str | None = None, + status_notes: list[dict] | None = None, ): self._id = id_ self._type = job_type @@ -116,6 +129,7 @@ def __init__( self._updated_at = updated_at self._workbook_name = workbook_name self._datasource_name = datasource_name + self._status_notes: list[dict] = status_notes or [] @property def id(self) -> str: @@ -149,6 +163,10 @@ def finish_code(self) -> int: def notes(self) -> list[str]: return self._notes + @property + def status_notes(self) -> list[dict]: + return self._status_notes + @property def mode(self) -> str | None: return self._mode @@ -222,6 +240,14 @@ def _parse_element(cls, element, ns): completed_at = parse_datetime(element.get("completedAt", None)) finish_code = int(element.get("finishCode", -1)) notes = [note.text for note in element.findall(".//t:notes", namespaces=ns)] or None + status_notes = [ + { + "type": note.get("type"), + "value": note.get("value"), + "text": note.get("text"), + } + for note in element.findall(".//t:statusNotes/t:statusNote", namespaces=ns) + ] or None mode = element.get("mode", None) workbook = element.find(".//t:workbook[@id]", namespaces=ns) workbook_id = workbook.get("id") if workbook is not None else None @@ -253,6 +279,7 @@ def _parse_element(cls, element, ns): updated_at, workbook_name, datasource_name, + status_notes, ) diff --git a/test/test_job.py b/test/test_job.py index 19f324d1e..7bfdd1840 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -63,6 +63,53 @@ def test_get_by_id(server: TSC.Server) -> None: assert job_id == job.id assert updated_at == job.updated_at assert job.notes == ["Job detail notes"] + # Regression for #1850: the response also carries a + # block per the public REST doc. Verify it now surfaces via job.status_notes. + assert job.status_notes == [ + { + "type": "CountOfUsersAddedToGroup", + "value": "5", + "text": "Description of how many users were added to the group during the import.", + } + ] + + +def test_status_notes_empty_when_absent() -> None: + # A job element with no yields an empty list, not None or an error. + xml = ( + b"" + b"" + b"" + ) + jobs = TSC.JobItem.from_response(xml, {"t": "http://tableau.com/api"}) + assert len(jobs) == 1 + assert jobs[0].status_notes == [] + + +def test_status_notes_multiple_entries() -> None: + # Multiple statusNote elements yield an ordered list of dicts. Any of type / + # value / text may be absent on a given note; missing attributes come back as None. + xml = ( + b"" + b"" + b"" + b"" + b"" + b"" + b"" + b"" + b"" + b"" + ) + jobs = TSC.JobItem.from_response(xml, {"t": "http://tableau.com/api"}) + assert len(jobs) == 1 + notes = jobs[0].status_notes + assert notes == [ + {"type": "line", "value": "0", "text": None}, + {"type": "errorCode", "value": "1", "text": None}, + {"type": "message", "value": "Actor does not have permission", "text": None}, + {"type": "username", "value": "unknown", "text": None}, + ] def test_get_before_signin(server: TSC.Server) -> None: From aa9e3a0bd3114e0dbb7ec41abd4784483fb89277 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Thu, 6 Aug 2026 15:55:18 -0700 Subject: [PATCH 06/15] fix: normalize CRLF line endings to LF in six Python files (#1816) * fix: normalize CRLF line endings to LF in six Python files Several files had CRLF line endings baked in from a prior black-version bump commit, causing ^M noise in diffs. Also reformats the multi-line .format() calls in data_alert_item.py and subscription_item.py to match current black formatting. Co-Authored-By: Claude Sonnet 5 * Run type and style checks on Python 3.13 now * Black format, run via Python 3.13 * chore: add .gitattributes to enforce LF line endings for Python files Prevents a repeat of the CRLF regression fixed in the prior commit by normalizing line endings at commit time. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .gitattributes | 4 + .github/workflows/meta-checks.yml | 2 +- samples/export.py | 214 ++++++------- samples/smoke_test.py | 30 +- tableauserverclient/datetime_helpers.py | 88 +++--- tableauserverclient/helpers/strings.py | 134 ++++----- tableauserverclient/models/column_item.py | 142 ++++----- .../models/subscription_item.py | 280 +++++++++--------- 8 files changed, 449 insertions(+), 445 deletions(-) diff --git a/.gitattributes b/.gitattributes index 040321c04..d4cabf8c3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,6 @@ tableauserverclient/_version.py export-subst tableauserverclient/bin/_version.py export-subst + +# Normalize line endings to LF to prevent CRLF from creeping into Python files +* text=auto +*.py text eol=lf diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml index 554febf64..abc3714dc 100644 --- a/.github/workflows/meta-checks.yml +++ b/.github/workflows/meta-checks.yml @@ -8,7 +8,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.10'] + python-version: ['3.13'] runs-on: ${{ matrix.os }} diff --git a/samples/export.py b/samples/export.py index c7f1cdb06..3200d491f 100644 --- a/samples/export.py +++ b/samples/export.py @@ -1,107 +1,107 @@ -#### -# This script demonstrates how to export a view using the Tableau -# Server Client. -# -# To run the script, you must have installed Python 3.7 or later. -#### - -import argparse -import logging - -import tableauserverclient as TSC - - -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", 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 - group = parser.add_mutually_exclusive_group(required=True) - group.add_argument( - "--pdf", dest="type", action="store_const", const=("populate_pdf", "PDFRequestOptions", "pdf", "pdf") - ) - group.add_argument( - "--png", dest="type", action="store_const", const=("populate_image", "ImageRequestOptions", "image", "png") - ) - group.add_argument( - "--csv", dest="type", action="store_const", const=("populate_csv", "CSVRequestOptions", "csv", "csv") - ) - # other options shown in explore_workbooks: workbook.download, workbook.preview_image - parser.add_argument( - "--language", help="Text such as 'Average' will appear in this language. Use values like fr, de, es, en" - ) - parser.add_argument("--workbook", action="store_true") - parser.add_argument("--custom_view", action="store_true") - - parser.add_argument("--file", "-f", help="filename to store the exported data") - parser.add_argument("--filter", "-vf", metavar="COLUMN:VALUE", help="View filter to apply to the view") - parser.add_argument("resource_id", help="LUID for the view or workbook") - - 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=True, http_options={"verify": False}) - with server.auth.sign_in(tableau_auth): - print("Connected") - if args.workbook: - item = server.workbooks.get_by_id(args.resource_id) - elif args.custom_view: - item = server.custom_views.get_by_id(args.resource_id) - else: - item = server.views.get_by_id(args.resource_id) - - if not item: - print(f"No item found for id {args.resource_id}") - exit(1) - - print(f"Item found: {item.name}") - # We have a number of different types and functions for each different export type. - # We encode that information above in the const=(...) parameter to the add_argument function to make - # the code automatically adapt for the type of export the user is doing. - # We unroll that information into methods we can call, or objects we can create by using getattr() - populate_func_name, option_factory_name, member_name, extension = args.type - populate = getattr(server.views, populate_func_name) - if args.workbook: - populate = getattr(server.workbooks, populate_func_name) - elif args.custom_view: - populate = getattr(server.custom_views, populate_func_name) - - option_factory = getattr(TSC, option_factory_name) - options: TSC.PDFRequestOptions = option_factory() - - if args.filter: - options = options.vf(*args.filter.split(":")) - - if args.language: - options.language = args.language - - if args.file: - filename = args.file - else: - filename = f"out-{options.language}.{extension}" - - populate(item, options) - with open(filename, "wb") as f: - if member_name == "csv": - f.writelines(getattr(item, member_name)) - else: - f.write(getattr(item, member_name)) - print("saved to " + filename) - - -if __name__ == "__main__": - main() +#### +# This script demonstrates how to export a view using the Tableau +# Server Client. +# +# To run the script, you must have installed Python 3.7 or later. +#### + +import argparse +import logging + +import tableauserverclient as TSC + + +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", 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 + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument( + "--pdf", dest="type", action="store_const", const=("populate_pdf", "PDFRequestOptions", "pdf", "pdf") + ) + group.add_argument( + "--png", dest="type", action="store_const", const=("populate_image", "ImageRequestOptions", "image", "png") + ) + group.add_argument( + "--csv", dest="type", action="store_const", const=("populate_csv", "CSVRequestOptions", "csv", "csv") + ) + # other options shown in explore_workbooks: workbook.download, workbook.preview_image + parser.add_argument( + "--language", help="Text such as 'Average' will appear in this language. Use values like fr, de, es, en" + ) + parser.add_argument("--workbook", action="store_true") + parser.add_argument("--custom_view", action="store_true") + + parser.add_argument("--file", "-f", help="filename to store the exported data") + parser.add_argument("--filter", "-vf", metavar="COLUMN:VALUE", help="View filter to apply to the view") + parser.add_argument("resource_id", help="LUID for the view or workbook") + + 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=True, http_options={"verify": False}) + with server.auth.sign_in(tableau_auth): + print("Connected") + if args.workbook: + item = server.workbooks.get_by_id(args.resource_id) + elif args.custom_view: + item = server.custom_views.get_by_id(args.resource_id) + else: + item = server.views.get_by_id(args.resource_id) + + if not item: + print(f"No item found for id {args.resource_id}") + exit(1) + + print(f"Item found: {item.name}") + # We have a number of different types and functions for each different export type. + # We encode that information above in the const=(...) parameter to the add_argument function to make + # the code automatically adapt for the type of export the user is doing. + # We unroll that information into methods we can call, or objects we can create by using getattr() + populate_func_name, option_factory_name, member_name, extension = args.type + populate = getattr(server.views, populate_func_name) + if args.workbook: + populate = getattr(server.workbooks, populate_func_name) + elif args.custom_view: + populate = getattr(server.custom_views, populate_func_name) + + option_factory = getattr(TSC, option_factory_name) + options: TSC.PDFRequestOptions = option_factory() + + if args.filter: + options = options.vf(*args.filter.split(":")) + + if args.language: + options.language = args.language + + if args.file: + filename = args.file + else: + filename = f"out-{options.language}.{extension}" + + populate(item, options) + with open(filename, "wb") as f: + if member_name == "csv": + f.writelines(getattr(item, member_name)) + else: + f.write(getattr(item, member_name)) + print("saved to " + filename) + + +if __name__ == "__main__": + main() diff --git a/samples/smoke_test.py b/samples/smoke_test.py index 237257014..778686abf 100644 --- a/samples/smoke_test.py +++ b/samples/smoke_test.py @@ -1,15 +1,15 @@ -# 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:") -logger.info(server.server_address) -logger.debug(TSC.server.endpoint.Endpoint.set_user_agent({})) +# 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:") +logger.info(server.server_address) +logger.debug(TSC.server.endpoint.Endpoint.set_user_agent({})) diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index e18db4c78..7beb0276e 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -1,44 +1,44 @@ -import datetime - -ZERO = datetime.timedelta(0) -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): - """UTC""" - - def utcoffset(self, dt): - return ZERO - - def tzname(self, dt): - return "UTC" - - def dst(self, dt): - return ZERO - - -utc = UTC() -TABLEAU_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" - - -def parse_datetime(date): - if date is None: - return None - - try: - return datetime.datetime.strptime(date, TABLEAU_DATE_FORMAT).replace(tzinfo=utc) - except ValueError: - return None - - -def format_datetime(date): - if date is None: - return None - - return date.astimezone(tz=utc).strftime(TABLEAU_DATE_FORMAT) +import datetime + +ZERO = datetime.timedelta(0) +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): + """UTC""" + + def utcoffset(self, dt): + return ZERO + + def tzname(self, dt): + return "UTC" + + def dst(self, dt): + return ZERO + + +utc = UTC() +TABLEAU_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" + + +def parse_datetime(date): + if date is None: + return None + + try: + return datetime.datetime.strptime(date, TABLEAU_DATE_FORMAT).replace(tzinfo=utc) + except ValueError: + return None + + +def format_datetime(date): + if date is None: + return None + + return date.astimezone(tz=utc).strftime(TABLEAU_DATE_FORMAT) diff --git a/tableauserverclient/helpers/strings.py b/tableauserverclient/helpers/strings.py index a095d606e..b1c868cc7 100644 --- a/tableauserverclient/helpers/strings.py +++ b/tableauserverclient/helpers/strings.py @@ -1,67 +1,67 @@ -from defusedxml.ElementTree import fromstring, tostring -from functools import singledispatch -from typing import TypeVar, cast, overload - -# the redact method can handle either strings or bytes, but it can't mix them. -# Generic type so we can write the actual logic once, then use singledispatch to -# create the replacement text with the correct type -T = TypeVar("T", str, bytes) - - -def _redact_any_type(xml: T, sensitive_word: T, replacement: T, encoding=None) -> T: - try: - root = fromstring(xml) - matches = root.findall(".//*[@password]") - for item in matches: - item.attrib["password"] = "********" - matches = root.findall(".//password") - for item in matches: - item.text = "********" - # tostring returns bytes unless an encoding value is passed; cast since - # the callers pass encoding="unicode" for str and None for bytes - return cast(T, tostring(root, encoding=encoding)) - except Exception: - # something about the xml handling failed. Just cut off the text at the first occurrence of "password" - location = xml.find(sensitive_word) - return xml[:location] + replacement - - -@singledispatch -def redact_xml(content): - # this will only be called if it didn't get directed to the str or bytes overloads - raise TypeError("Redaction only works on xml saved as str or bytes") - - -@redact_xml.register -def _(xml: str) -> str: - out = _redact_any_type(xml, "password", "...[redacted]", encoding="unicode") - return out - - -@redact_xml.register # type: ignore[no-redef] -def _(xml: bytes) -> bytes: - return cast(bytes, _redact_any_type(xml, b"password", b"..[redacted]")) - - -@overload -def nullable_str_to_int(value: None) -> None: ... - - -@overload -def nullable_str_to_int(value: str) -> int: ... - - -def nullable_str_to_int(value): - return int(value) if value is not None else None - - -@overload -def nullable_str_to_bool(value: None) -> None: ... - - -@overload -def nullable_str_to_bool(value: str) -> bool: ... - - -def nullable_str_to_bool(value): - return str(value).lower() == "true" if value is not None else None +from defusedxml.ElementTree import fromstring, tostring +from functools import singledispatch +from typing import TypeVar, cast, overload + +# the redact method can handle either strings or bytes, but it can't mix them. +# Generic type so we can write the actual logic once, then use singledispatch to +# create the replacement text with the correct type +T = TypeVar("T", str, bytes) + + +def _redact_any_type(xml: T, sensitive_word: T, replacement: T, encoding=None) -> T: + try: + root = fromstring(xml) + matches = root.findall(".//*[@password]") + for item in matches: + item.attrib["password"] = "********" + matches = root.findall(".//password") + for item in matches: + item.text = "********" + # tostring returns bytes unless an encoding value is passed; cast since + # the callers pass encoding="unicode" for str and None for bytes + return cast(T, tostring(root, encoding=encoding)) + except Exception: + # something about the xml handling failed. Just cut off the text at the first occurrence of "password" + location = xml.find(sensitive_word) + return xml[:location] + replacement + + +@singledispatch +def redact_xml(content): + # this will only be called if it didn't get directed to the str or bytes overloads + raise TypeError("Redaction only works on xml saved as str or bytes") + + +@redact_xml.register +def _(xml: str) -> str: + out = _redact_any_type(xml, "password", "...[redacted]", encoding="unicode") + return out + + +@redact_xml.register # type: ignore[no-redef] +def _(xml: bytes) -> bytes: + return cast(bytes, _redact_any_type(xml, b"password", b"..[redacted]")) + + +@overload +def nullable_str_to_int(value: None) -> None: ... + + +@overload +def nullable_str_to_int(value: str) -> int: ... + + +def nullable_str_to_int(value): + return int(value) if value is not None else None + + +@overload +def nullable_str_to_bool(value: None) -> None: ... + + +@overload +def nullable_str_to_bool(value: str) -> bool: ... + + +def nullable_str_to_bool(value): + return str(value).lower() == "true" if value is not None else None diff --git a/tableauserverclient/models/column_item.py b/tableauserverclient/models/column_item.py index 739b7e8ef..dd4c1254f 100644 --- a/tableauserverclient/models/column_item.py +++ b/tableauserverclient/models/column_item.py @@ -1,71 +1,71 @@ -from defusedxml.ElementTree import fromstring - -from .property_decorators import property_not_empty - - -class ColumnItem: - def __init__(self, name, description=None): - self._id = None - self.description = description - self.name = name - - def __repr__(self): - return f"<{self.__class__.__name__} {self._id} {self.name} {self.description}>" - - @property - def id(self): - return self._id - - @property - def name(self): - return self._name - - @name.setter - @property_not_empty - def name(self, value): - self._name = value - - @property - def description(self): - return self._description - - @description.setter - def description(self, value): - self._description = value - - @property - def remote_type(self): - return self._remote_type - - def _set_values(self, id, name, description, remote_type): - if id is not None: - self._id = id - if name: - self._name = name - if description: - self.description = description - if remote_type: - self._remote_type = remote_type - - @classmethod - def from_response(cls, resp, ns): - all_column_items = list() - parsed_response = fromstring(resp) - all_column_xml = parsed_response.findall(".//t:column", namespaces=ns) - - for column_xml in all_column_xml: - id, name, description, remote_type = cls._parse_element(column_xml, ns) - column_item = cls(name) - column_item._set_values(id, name, description, remote_type) - all_column_items.append(column_item) - - return all_column_items - - @staticmethod - def _parse_element(column_xml, ns): - id = column_xml.get("id", None) - name = column_xml.get("name", None) - description = column_xml.get("description", None) - remote_type = column_xml.get("remoteType", None) - - return id, name, description, remote_type +from defusedxml.ElementTree import fromstring + +from .property_decorators import property_not_empty + + +class ColumnItem: + def __init__(self, name, description=None): + self._id = None + self.description = description + self.name = name + + def __repr__(self): + return f"<{self.__class__.__name__} {self._id} {self.name} {self.description}>" + + @property + def id(self): + return self._id + + @property + def name(self): + return self._name + + @name.setter + @property_not_empty + def name(self, value): + self._name = value + + @property + def description(self): + return self._description + + @description.setter + def description(self, value): + self._description = value + + @property + def remote_type(self): + return self._remote_type + + def _set_values(self, id, name, description, remote_type): + if id is not None: + self._id = id + if name: + self._name = name + if description: + self.description = description + if remote_type: + self._remote_type = remote_type + + @classmethod + def from_response(cls, resp, ns): + all_column_items = list() + parsed_response = fromstring(resp) + all_column_xml = parsed_response.findall(".//t:column", namespaces=ns) + + for column_xml in all_column_xml: + id, name, description, remote_type = cls._parse_element(column_xml, ns) + column_item = cls(name) + column_item._set_values(id, name, description, remote_type) + all_column_items.append(column_item) + + return all_column_items + + @staticmethod + def _parse_element(column_xml, ns): + id = column_xml.get("id", None) + name = column_xml.get("name", None) + description = column_xml.get("description", None) + remote_type = column_xml.get("remoteType", None) + + return id, name, description, remote_type diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index f53a1e0cf..9ae99e398 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -1,140 +1,140 @@ -from typing import TYPE_CHECKING - -from defusedxml.ElementTree import fromstring - -from .property_decorators import property_is_boolean -from .target import Target -from tableauserverclient.models import ScheduleItem - -if TYPE_CHECKING: - from .target import Target - - -class SubscriptionItem: - def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None: - self._id = None - self.attach_image = True - self.attach_pdf = False - self.message = None - self.page_orientation = None - self.page_size_option = None - self.schedule_id = schedule_id - self.send_if_view_empty = True - self.subject = subject - self.suspended = False - self.target = target - self.user_id = user_id - self.schedule = None - - def __repr__(self) -> str: - if self.id is not None: - return " bool: - return self._attach_image - - @attach_image.setter - @property_is_boolean - def attach_image(self, value: bool): - self._attach_image = value - - @property - def attach_pdf(self) -> bool: - return self._attach_pdf - - @attach_pdf.setter - @property_is_boolean - def attach_pdf(self, value: bool) -> None: - self._attach_pdf = value - - @property - def send_if_view_empty(self) -> bool: - return self._send_if_view_empty - - @send_if_view_empty.setter - @property_is_boolean - def send_if_view_empty(self, value: bool) -> None: - self._send_if_view_empty = value - - @property - def suspended(self) -> bool: - return self._suspended - - @suspended.setter - @property_is_boolean - def suspended(self, value: bool) -> None: - self._suspended = value - - @classmethod - def from_response(cls: type, xml: bytes, ns) -> list["SubscriptionItem"]: - parsed_response = fromstring(xml) - all_subscriptions_xml = parsed_response.findall(".//t:subscription", namespaces=ns) - - all_subscriptions = [SubscriptionItem._parse_element(x, ns) for x in all_subscriptions_xml] - return all_subscriptions - - @classmethod - def _parse_element(cls, element, ns): - schedule_element = element.find(".//t:schedule", namespaces=ns) - content_element = element.find(".//t:content", namespaces=ns) - user_element = element.find(".//t:user", namespaces=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 - if content_element is not None: - target = Target(content_element.get("id", None), content_element.get("type")) - send_if_view_empty = string_to_bool(content_element.get("sendIfViewEmpty", "")) - - # User element - user_id = None - if user_element is not None: - user_id = user_element.get("id", None) - - # Main attributes - id_ = element.get("id", None) - subject = element.get("subject", None) - attach_image = string_to_bool(element.get("attachImage", "")) - attach_pdf = string_to_bool(element.get("attachPdf", "")) - message = element.get("message", None) - page_orientation = element.get("pageOrientation", None) - page_size_option = element.get("pageSizeOption", None) - suspended = string_to_bool(element.get("suspended", "")) - - # Create SubscriptionItem and set fields - sub = cls(subject, schedule_id, user_id, target) - sub._id = id_ - sub.attach_image = attach_image - sub.attach_pdf = attach_pdf - sub.message = message - sub.page_orientation = page_orientation - sub.page_size_option = page_size_option - sub.send_if_view_empty = send_if_view_empty - sub.suspended = suspended - sub.schedule = schedule - - return sub - - -# Used to convert string represented boolean to a boolean type -def string_to_bool(s: str) -> bool: - return s.lower() == "true" +from typing import TYPE_CHECKING + +from defusedxml.ElementTree import fromstring + +from .property_decorators import property_is_boolean +from .target import Target +from tableauserverclient.models import ScheduleItem + +if TYPE_CHECKING: + from .target import Target + + +class SubscriptionItem: + def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None: + self._id = None + self.attach_image = True + self.attach_pdf = False + self.message = None + self.page_orientation = None + self.page_size_option = None + self.schedule_id = schedule_id + self.send_if_view_empty = True + self.subject = subject + self.suspended = False + self.target = target + self.user_id = user_id + self.schedule = None + + def __repr__(self) -> str: + if self.id is not None: + return " bool: + return self._attach_image + + @attach_image.setter + @property_is_boolean + def attach_image(self, value: bool): + self._attach_image = value + + @property + def attach_pdf(self) -> bool: + return self._attach_pdf + + @attach_pdf.setter + @property_is_boolean + def attach_pdf(self, value: bool) -> None: + self._attach_pdf = value + + @property + def send_if_view_empty(self) -> bool: + return self._send_if_view_empty + + @send_if_view_empty.setter + @property_is_boolean + def send_if_view_empty(self, value: bool) -> None: + self._send_if_view_empty = value + + @property + def suspended(self) -> bool: + return self._suspended + + @suspended.setter + @property_is_boolean + def suspended(self, value: bool) -> None: + self._suspended = value + + @classmethod + def from_response(cls: type, xml: bytes, ns) -> list["SubscriptionItem"]: + parsed_response = fromstring(xml) + all_subscriptions_xml = parsed_response.findall(".//t:subscription", namespaces=ns) + + all_subscriptions = [SubscriptionItem._parse_element(x, ns) for x in all_subscriptions_xml] + return all_subscriptions + + @classmethod + def _parse_element(cls, element, ns): + schedule_element = element.find(".//t:schedule", namespaces=ns) + content_element = element.find(".//t:content", namespaces=ns) + user_element = element.find(".//t:user", namespaces=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 + if content_element is not None: + target = Target(content_element.get("id", None), content_element.get("type")) + send_if_view_empty = string_to_bool(content_element.get("sendIfViewEmpty", "")) + + # User element + user_id = None + if user_element is not None: + user_id = user_element.get("id", None) + + # Main attributes + id_ = element.get("id", None) + subject = element.get("subject", None) + attach_image = string_to_bool(element.get("attachImage", "")) + attach_pdf = string_to_bool(element.get("attachPdf", "")) + message = element.get("message", None) + page_orientation = element.get("pageOrientation", None) + page_size_option = element.get("pageSizeOption", None) + suspended = string_to_bool(element.get("suspended", "")) + + # Create SubscriptionItem and set fields + sub = cls(subject, schedule_id, user_id, target) + sub._id = id_ + sub.attach_image = attach_image + sub.attach_pdf = attach_pdf + sub.message = message + sub.page_orientation = page_orientation + sub.page_size_option = page_size_option + sub.send_if_view_empty = send_if_view_empty + sub.suspended = suspended + sub.schedule = schedule + + return sub + + +# Used to convert string represented boolean to a boolean type +def string_to_bool(s: str) -> bool: + return s.lower() == "true" From 54e51f5bcdd529857188450b2fa02bd1d23b0deb Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Sat, 8 Aug 2026 01:03:13 -0700 Subject: [PATCH 07/15] samples: align sign-in short flags with tabcmd Restore -t for --site, -u for --username, -p for --password; drop short flags on --token-name and --token-value. This matches tabcmd's canonical short flags in tabcmd/execution/parent_parser.py so users running both tools have one convention to remember. The initial refactor picked new short flags without noticing that the old samples/login.py already followed tabcmd's convention (-p was --password, -t was --site). Reassigning -p to --token-name meant `python login.py -p ` silently sent the password as a token name. Co-Authored-By: Claude Opus 4.7 (1M context) --- samples/_shared.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/samples/_shared.py b/samples/_shared.py index e728c227c..2bbdad3dc 100644 --- a/samples/_shared.py +++ b/samples/_shared.py @@ -47,26 +47,26 @@ def add_common_arguments(parser: argparse.ArgumentParser) -> None: are pulled from the environment or prompted for interactively. """ parser.add_argument("--server", "-s", help="server address (env: TABLEAU_SERVER)") - parser.add_argument("--site", "-S", help="site content URL (env: TABLEAU_SITE)") + parser.add_argument("--site", "-t", help="site content URL (env: TABLEAU_SITE)") parser.add_argument( "--token-name", - "-p", help="name of the personal access token used to sign into the server " "(env: TABLEAU_TOKEN_NAME)", ) parser.add_argument( "--token-value", - "-v", help="value of the personal access token used to sign into the server " "(env: TABLEAU_TOKEN_VALUE). Prefer the env var or interactive prompt over the " "command line so the secret does not land in shell history.", ) parser.add_argument( "--username", + "-u", help="username to sign into the server (env: TABLEAU_USERNAME). Only used if " "no personal access token is supplied.", ) parser.add_argument( "--password", + "-p", help="password (env: TABLEAU_PASSWORD). Prefer the env var or interactive " "prompt over the command line.", ) parser.add_argument( From 5b5c90aa026cd9ab094cd063e312171fc2ec06b3 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Sun, 16 Aug 2026 05:06:20 -0700 Subject: [PATCH 08/15] feat: expose refreshExtractTriggered on SubscriptionItem (#1658) The Tableau REST API supports a `refreshExtractTriggered="true"` attribute on subscription payloads that makes the subscription fire when its referenced schedule's extract refresh completes, rather than on the schedule's time trigger. On Tableau Cloud, this is the wire form of an "On Extract Refresh" subscription. TSC never exposed this attribute; users trying to create these subscriptions were passing `schedule_id=None` and hitting a confusing wire error deep in the endpoint layer. Changes: - `SubscriptionItem.on_extract_refresh(...)` classmethod factory constructs a subscription with an extract-refresh schedule id and the flag set. - `refresh_extract_triggered` exposed as a property with a docstring covering the two ways the server surprises callers (server rejects True with a non-extract schedule; server silently clears the flag when a schedule change is included in an update). - `Subscriptions.create()` and `.update()` now raise `ValueError` up front when `schedule_id` is missing, so the wire error becomes an actionable client-side message. - `create_req` emits `refreshExtractTriggered="true"` only when set; `update_req` emits both true and false so callers can turn the flag off on an existing subscription. - `_parse_element` reads the attribute back into the property; parse continues to accept inline-schedule responses (schedule_id=None). Tests cover: factory sets flag + schedule id; default false; create_req emit-when-set/omit-when-false; update_req always emits; parse round-trip for both true and missing; parse of inline-schedule responses; create() and update() reject missing schedule_id. Related to #1658. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../models/subscription_item.py | 130 ++++++++++++ .../server/endpoint/subscriptions_endpoint.py | 11 + tableauserverclient/server/request_factory.py | 11 + test/test_subscription.py | 193 ++++++++++++++++++ 4 files changed, 345 insertions(+) diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index 9ae99e398..660bd12b6 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -11,6 +11,50 @@ class SubscriptionItem: + """A subscription that sends a view or workbook to a user on a schedule. + + Subscriptions fire on one of two triggers: + + 1. **Time-based** (the common case): the referenced schedule's time + trigger runs -- e.g. a "Weekly Monday 8am" schedule fires the + subscription every Monday at 8am. Construct these with the normal + ``SubscriptionItem(subject, schedule_id, user_id, target)`` form. + + 2. **Extract-refresh-triggered**: the referenced schedule's extract + refresh completes -- the subscription fires alongside the refresh, + so recipients always get the freshest data. Use the + :meth:`on_extract_refresh` classmethod to construct these; it sets + :attr:`refresh_extract_triggered` to ``True`` for you. + + In the Cloud web UI, extract-refresh-triggered subscriptions show up + as schedule "On Extract Refresh". At the REST API level there is no + "On Extract Refresh" schedule type; instead the subscription + references an existing extract-refresh schedule *and* sets + ``refreshExtractTriggered=true`` on the payload. + + Examples + -------- + Time-based subscription: + + >>> sub = TSC.SubscriptionItem( + ... subject="Weekly report", + ... schedule_id=weekly_schedule.id, + ... user_id=user.id, + ... target=TSC.Target(view.id, "view"), + ... ) + >>> server.subscriptions.create(sub) + + Extract-refresh-triggered subscription: + + >>> sub = TSC.SubscriptionItem.on_extract_refresh( + ... subject="Send when refresh finishes", + ... extract_refresh_schedule_id=nightly_refresh_schedule.id, + ... user_id=user.id, + ... target=TSC.Target(view.id, "view"), + ... ) + >>> server.subscriptions.create(sub) + """ + def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None: self._id = None self.attach_image = True @@ -25,6 +69,56 @@ def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target self.target = target self.user_id = user_id self.schedule = None + self._refresh_extract_triggered: bool = False + + @classmethod + def on_extract_refresh( + cls, + subject: str, + extract_refresh_schedule_id: str, + user_id: str, + target: "Target", + ) -> "SubscriptionItem": + """Construct a subscription that fires when an extract refresh runs. + + The subscription references an existing extract-refresh schedule and + will fire alongside that schedule's extract refresh, so recipients + get the freshest data. Server-side this maps to + ``refreshExtractTriggered=true`` on the subscription entity; the Cloud + UI surfaces the same state as schedule type "On Extract Refresh". + + Parameters + ---------- + subject : str + Subscription subject line, shown in the delivered email. + extract_refresh_schedule_id : str + ID of an existing schedule that owns an extract refresh. On Cloud + list schedules with ``server.schedules.get()`` and filter to the + extract-refresh schedules; on-prem the same list is populated by + the site's server-authored schedules. + user_id : str + ID of the recipient user. + target : Target + The workbook or view to send. + + Returns + ------- + SubscriptionItem + A subscription with ``refresh_extract_triggered`` set to True. + Pass to ``server.subscriptions.create(...)`` to create it. + + Notes + ----- + This factory does not validate that ``extract_refresh_schedule_id`` + actually references an extract-refresh schedule. Referencing a + non-extract schedule with ``refresh_extract_triggered=True`` is a + server-side error and will surface when ``create()`` is called. + + Related to tableau/server-client-python#1658. + """ + sub = cls(subject, extract_refresh_schedule_id, user_id, target) + sub.refresh_extract_triggered = True + return sub def __repr__(self) -> str: if self.id is not None: @@ -74,6 +168,40 @@ def suspended(self) -> bool: def suspended(self, value: bool) -> None: self._suspended = value + @property + def refresh_extract_triggered(self) -> bool: + """Whether this subscription fires when its schedule's extract refresh runs. + + When True, the subscription must reference an existing extract-refresh + schedule (via ``schedule_id``) and will fire alongside that schedule's + extract refresh. When False (the default), the subscription fires on + the schedule's time trigger like every other subscription. + + The Cloud web UI surfaces the True state as schedule type "On Extract + Refresh"; there is no such REST-API schedule type, so callers must set + this flag explicitly. Prefer :meth:`on_extract_refresh` when + constructing new extract-refresh-triggered subscriptions -- it wires + up ``schedule_id`` and this flag together in one call. + + Setting this to True on a subscription that references a non-extract + schedule (Subscription, Flow, System, etc.) is a server-side error; + the ``create()``/``update()`` call will raise. TSC does not fetch the + referenced schedule to validate this client-side. + + **Updating an existing subscription:** if an update changes the + referenced schedule, the server silently forces this flag back to + False on that same call, regardless of what the client sent. To + convert a time-based subscription into an extract-refresh-triggered + one, issue two updates: first change ``schedule_id``, then set + ``refresh_extract_triggered = True`` on a second call. + """ + return self._refresh_extract_triggered + + @refresh_extract_triggered.setter + @property_is_boolean + def refresh_extract_triggered(self, value: bool) -> None: + self._refresh_extract_triggered = value + @classmethod def from_response(cls: type, xml: bytes, ns) -> list["SubscriptionItem"]: parsed_response = fromstring(xml) @@ -119,6 +247,7 @@ def _parse_element(cls, element, ns): page_orientation = element.get("pageOrientation", None) page_size_option = element.get("pageSizeOption", None) suspended = string_to_bool(element.get("suspended", "")) + refresh_extract_triggered = string_to_bool(element.get("refreshExtractTriggered", "")) # Create SubscriptionItem and set fields sub = cls(subject, schedule_id, user_id, target) @@ -131,6 +260,7 @@ def _parse_element(cls, element, ns): sub.send_if_view_empty = send_if_view_empty sub.suspended = suspended sub.schedule = schedule + sub.refresh_extract_triggered = refresh_extract_triggered return sub diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index d69424e44..6360ebf14 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -43,6 +43,11 @@ def create(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item: error = "No Susbcription provided" raise ValueError(error) + if not subscription_item.schedule_id: + # See tableau/server-client-python#1658: users trying to create an + # "On Extract Refresh" subscription pass schedule_id=None and hit + # a confusing wire-layer error. Point them at the factory. + raise ValueError("schedule_id is required; see SubscriptionItem.on_extract_refresh") logger.info(f"Creating a subscription ({subscription_item})") url = self.baseurl create_req = RequestFactory.Subscription.create_req(subscription_item) @@ -63,6 +68,12 @@ def update(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item.id: error = "Subscription item missing ID. Subscription must be retrieved from server first." raise MissingRequiredFieldError(error) + if not subscription_item.schedule_id: + # A subscription round-tripped from an inline-schedule response + # (Cloud/TOL) has schedule_id=None. Updating it in that state + # sends with no id and hits the same wire-layer error + # that create() guards against. See tableau/server-client-python#1658. + raise ValueError("schedule_id is required to update a subscription") url = f"{self.baseurl}/{subscription_item.id}" update_req = RequestFactory.Subscription.update_req(subscription_item) server_response = self.put_request(url, update_req) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index fc4694c01..c2dabc330 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1334,6 +1334,13 @@ def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt subscription_element.attrib["pageOrientation"] = subscription_item.page_orientation if subscription_item.page_size_option is not None: subscription_element.attrib["pageSizeOption"] = subscription_item.page_size_option + # On create, only emit refreshExtractTriggered when True -- server default + # is False, and emitting the attribute unconditionally would surface as a + # payload change on servers that treat absence differently from an explicit + # False. update_req is asymmetric here: it must emit False to enable the + # True -> False transition on an existing subscription. + if subscription_item.refresh_extract_triggered: + subscription_element.attrib["refreshExtractTriggered"] = "true" # Content element content_element = ET.SubElement(subscription_element, "content") @@ -1368,6 +1375,10 @@ def update_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt subscription.attrib["pageSizeOption"] = subscription_item.page_size_option if subscription_item.suspended is not None: subscription.attrib["suspended"] = str(subscription_item.suspended).lower() + # update_req always emits the flag so callers can turn it off. The + # server retains the prior value when the attribute is absent, so + # omission would silently prevent True -> False transitions. + subscription.attrib["refreshExtractTriggered"] = str(subscription_item.refresh_extract_triggered).lower() # Schedule element schedule = ET.SubElement(subscription, "schedule") diff --git a/test/test_subscription.py b/test/test_subscription.py index 7c78cc57d..045943aac 100644 --- a/test/test_subscription.py +++ b/test/test_subscription.py @@ -100,3 +100,196 @@ def test_delete_subscription(server: TSC.Server) -> None: with requests_mock.mock() as m: m.delete(server.subscriptions.baseurl + "/78e9318d-2d29-4d67-b60f-3f2f5fd89ecc", status_code=204) server.subscriptions.delete("78e9318d-2d29-4d67-b60f-3f2f5fd89ecc") + + +# ----------------------------------------------------------------- +# refresh_extract_triggered (aka "On Extract Refresh" subscriptions) +# ----------------------------------------------------------------- + + +def test_create_rejects_none_schedule_id(server: TSC.Server) -> None: + """Regression for tableau/server-client-python#1658: users trying to create + an 'On Extract Refresh' subscription would pass schedule_id=None. Point them + at on_extract_refresh() instead of letting the failure surface deep in + the wire layer as a ServerResponseError. The check lives in create() (not + __init__) so parse can still build items from server responses that use + the inline-schedule form (no schedule id on the wire). + """ + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem("subject", None, "user-id", target) # type: ignore[arg-type] + with pytest.raises(ValueError, match="on_extract_refresh"): + server.subscriptions.create(sub) + + +def test_create_rejects_empty_schedule_id(server: TSC.Server) -> None: + """Same failure mode as None: an empty-string schedule_id would serialize + as and hit a confusing server error. + """ + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem("subject", "", "user-id", target) + with pytest.raises(ValueError, match="on_extract_refresh"): + server.subscriptions.create(sub) + + +def test_subscription_defaults_refresh_extract_triggered_false(server: TSC.Server) -> None: + """A default SubscriptionItem does not opt into extract-refresh triggering.""" + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem("subject", "sched-id", "user-id", target) + assert sub.refresh_extract_triggered is False + + +def test_on_extract_refresh_factory_sets_flag(server: TSC.Server) -> None: + """The on_extract_refresh factory produces a subscription with the flag set + and the extract-refresh schedule id in place -- server rejects a payload + that has the flag without a schedule reference, so both must be set together. + """ + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem.on_extract_refresh( + subject="On refresh", + extract_refresh_schedule_id="refresh-sched-id", + user_id="user-id", + target=target, + ) + assert sub.refresh_extract_triggered is True + assert sub.schedule_id == "refresh-sched-id" + assert sub.subject == "On refresh" + assert sub.user_id == "user-id" + assert sub.target is target + + +def test_create_req_emits_refresh_extract_triggered_when_set(server: TSC.Server) -> None: + """When the flag is set, the outbound XML should carry + refreshExtractTriggered='true' on the element. + """ + from tableauserverclient.server.request_factory import RequestFactory + + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem.on_extract_refresh( + subject="On refresh", + extract_refresh_schedule_id="refresh-sched-id", + user_id="user-id", + target=target, + ) + body = RequestFactory.Subscription.create_req(sub).decode("utf-8") + assert 'refreshExtractTriggered="true"' in body + + +def test_create_req_omits_refresh_extract_triggered_when_false(server: TSC.Server) -> None: + """A default subscription must not emit refreshExtractTriggered=false. Some + servers treat absence and False differently; we send only when the caller + has explicitly opted in. + """ + from tableauserverclient.server.request_factory import RequestFactory + + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem("subject", "sched-id", "user-id", target) + body = RequestFactory.Subscription.create_req(sub).decode("utf-8") + assert "refreshExtractTriggered" not in body + + +def test_parse_response_reads_refresh_extract_triggered(server: TSC.Server) -> None: + """A subscription XML element carrying refreshExtractTriggered='true' + parses back into refresh_extract_triggered=True on the SubscriptionItem. + """ + xml = ( + b'' + b" " + b' ' + b' ' + b' ' + b' ' + b" " + b" " + b"" + ) + subs = TSC.SubscriptionItem.from_response(xml, {"t": "http://tableau.com/api"}) + assert len(subs) == 1 + assert subs[0].refresh_extract_triggered is True + assert subs[0].schedule_id == "refresh-sched-1" + + +def test_update_rejects_missing_schedule_id(server: TSC.Server) -> None: + """A subscription round-tripped from an inline-schedule response has + schedule_id=None. Calling update() on it would send with no id + and hit a confusing wire-layer error. Catch it at the endpoint instead. + """ + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem("subject", "sched-id", "user-id", target) + sub._id = "existing-sub-id" # type: ignore[assignment] + sub.schedule_id = None # type: ignore[assignment] + with pytest.raises(ValueError, match="schedule_id is required"): + server.subscriptions.update(sub) + + +def test_update_req_emits_refresh_extract_triggered_when_true(server: TSC.Server) -> None: + """When the flag is True, update_req must emit refreshExtractTriggered='true'.""" + from tableauserverclient.server.request_factory import RequestFactory + + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem.on_extract_refresh( + subject="On refresh", + extract_refresh_schedule_id="refresh-sched-id", + user_id="user-id", + target=target, + ) + body = RequestFactory.Subscription.update_req(sub).decode("utf-8") + assert 'refreshExtractTriggered="true"' in body + + +def test_update_req_emits_refresh_extract_triggered_when_false(server: TSC.Server) -> None: + """update_req must emit refreshExtractTriggered='false' so callers can turn + the flag off. The server retains the prior value when the attribute is + absent, so omission would silently prevent True -> False transitions. + """ + from tableauserverclient.server.request_factory import RequestFactory + + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem("subject", "sched-id", "user-id", target) + assert sub.refresh_extract_triggered is False + body = RequestFactory.Subscription.update_req(sub).decode("utf-8") + assert 'refreshExtractTriggered="false"' in body + + +def test_parse_response_with_inline_schedule_no_id(server: TSC.Server) -> None: + """Regression: on Cloud/TOL the server may return a element with + no id attribute (the full schedule is inlined instead). Parse must handle + this without raising -- the constructor cannot demand a schedule_id here. + """ + xml = ( + b'' + b" " + b' ' + b' ' + b' ' + b' ' + b" " + b' ' + b" " + b" " + b"" + ) + subs = TSC.SubscriptionItem.from_response(xml, {"t": "http://tableau.com/api"}) + assert len(subs) == 1 + assert subs[0].schedule_id is None + assert subs[0].schedule is not None + + +def test_parse_response_missing_refresh_extract_triggered_defaults_false(server: TSC.Server) -> None: + """Backward compatibility: a subscription XML element without the attribute + parses back to refresh_extract_triggered=False. + """ + xml = ( + b'' + b" " + b' ' + b' ' + b' ' + b' ' + b" " + b" " + b"" + ) + subs = TSC.SubscriptionItem.from_response(xml, {"t": "http://tableau.com/api"}) + assert len(subs) == 1 + assert subs[0].refresh_extract_triggered is False From 4207f7f668d1762bc954757c0071887c879673c1 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 18 Aug 2026 01:02:50 -0700 Subject: [PATCH 09/15] Address fresh-eyes review on refreshExtractTriggered subscriptions - Docstring on `refresh_extract_triggered` now warns about the manual- build update() footgun: because every subscriptions.update() payload carries the attribute, a caller who builds a fresh SubscriptionItem locally, stamps _id, and updates will silently flip an existing on-extract-refresh subscription off. Fetch first. - Soften create()'s "schedule_id is required" error so someone who just forgot to set schedule_id on a time-based subscription doesn't get steered exclusively toward SubscriptionItem.on_extract_refresh(...); the factory is now mentioned as a conditional pointer. - __init__'s schedule_id parameter is now typed str | None, matching the real state: _parse_element sets it to None on inline-schedule responses. Drop the two `# type: ignore` markers in test/test_subscription.py that were papering over the earlier lie. - create_req asserts schedule_id non-None to satisfy mypy after the parameter widening; subscriptions.create() already guards this path before request emission. - Add samples/create_extract_refresh_subscription.py demonstrating the full flow: sign in, resolve view/workbook and user by name, pick an extract-refresh schedule from the schedules list, build the subscription via on_extract_refresh(), post it. Highest-leverage discoverability artifact for callers searching "on extract refresh". - CHANGELOG entry. --- CHANGELOG.md | 10 ++ .../create_extract_refresh_subscription.py | 108 ++++++++++++++++++ .../models/subscription_item.py | 11 +- .../server/endpoint/subscriptions_endpoint.py | 5 +- tableauserverclient/server/request_factory.py | 5 +- test/test_subscription.py | 4 +- 6 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 samples/create_extract_refresh_subscription.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fc1430d38..5e3985bd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,16 @@ ## Unreleased +* Added support for "On Extract Refresh" subscriptions. These are Tableau + Cloud subscriptions that fire when a referenced extract-refresh schedule + completes, rather than on a time trigger, so recipients always get the + freshest data. New `SubscriptionItem.on_extract_refresh(subject, + extract_refresh_schedule_id, user_id, target)` classmethod is the + recommended way to construct them, and `SubscriptionItem.refresh_extract_triggered` + is a boolean property that reflects the `refreshExtractTriggered` + attribute on the wire. `subscriptions.create()` and `.update()` now + raise `ValueError` if `schedule_id` is missing (previously a confusing + server-side error). Fixes #1658. * Added `Projects.get_by_path(path)` to look up a project by its slash-separated hierarchy path (e.g. `"Marketing/Q1 Reports"`). The walk is performed level by level using the REST API name filter, so a path with *n* components issues *n* diff --git a/samples/create_extract_refresh_subscription.py b/samples/create_extract_refresh_subscription.py new file mode 100644 index 000000000..ae032a0bd --- /dev/null +++ b/samples/create_extract_refresh_subscription.py @@ -0,0 +1,108 @@ +#### +# This script creates a Tableau Cloud "On Extract Refresh" subscription: +# a subscription that fires when an extract-refresh schedule completes, +# rather than on the schedule's time trigger. Recipients get the email +# alongside the refresh, so they always see the freshest data. +# +# What it does: +# 1. Sign in. +# 2. Look up the target view or workbook by name. +# 3. List extract-refresh schedules and pick the one you named. +# 4. Build the subscription via SubscriptionItem.on_extract_refresh(). +# 5. Call subscriptions.create() and print the new subscription id. +# +# On Tableau Server this same script works as long as the schedule you +# reference is an extract-refresh schedule; the "On Extract Refresh" +# terminology is Cloud-UI-specific but the REST attribute +# (refreshExtractTriggered) is the same on both. +# +# Requires Python 3.10 or later. +#### + + +import argparse +import logging + +import tableauserverclient as TSC + + +def usage(args): + parser = argparse.ArgumentParser(description="Create an On Extract Refresh subscription for a view or workbook.") + # Common options; keep in sync across samples. + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", default="", help="site content URL") + parser.add_argument("--token-name", "-p", required=True, help="personal access token name") + parser.add_argument("--token-value", "-v", required=True, help="personal access token value") + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + ) + # Sample-specific options. + parser.add_argument("--subject", required=True, help="subscription subject line") + parser.add_argument("--schedule", required=True, help="name of the extract-refresh schedule to attach to") + parser.add_argument("--user", required=True, help="username of the subscription recipient") + target = parser.add_mutually_exclusive_group(required=True) + target.add_argument("--view", help="name of the view to send") + target.add_argument("--workbook", help="name of the workbook to send") + return parser.parse_args(args) + + +def _find_one(items, label, name): + matches = [i for i in items if i.name == name] + if len(matches) != 1: + raise SystemExit(f"expected exactly one {label} named {name!r}, found {len(matches)}") + return matches[0] + + +def find_extract_refresh_schedule(server, name): + schedules = [s for s in TSC.Pager(server.schedules) if s.schedule_type == TSC.ScheduleItem.Type.Extract] + return _find_one(schedules, "extract-refresh schedule", name) + + +def find_view(server, name): + return _find_one(list(TSC.Pager(server.views)), "view", name) + + +def find_workbook(server, name): + return _find_one(list(TSC.Pager(server.workbooks)), "workbook", name) + + +def find_user(server, name): + return _find_one(list(TSC.Pager(server.users)), "user", name) + + +def run(args): + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) + + auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(auth): + schedule = find_extract_refresh_schedule(server, args.schedule) + user = find_user(server, args.user) + if args.view: + content = find_view(server, args.view) + target = TSC.Target(content.id, "view") + else: + content = find_workbook(server, args.workbook) + target = TSC.Target(content.id, "workbook") + + subscription = TSC.SubscriptionItem.on_extract_refresh( + subject=args.subject, + extract_refresh_schedule_id=schedule.id, + user_id=user.id, + target=target, + ) + created = server.subscriptions.create(subscription) + print(f"Created subscription {created.id}: {created.subject!r} on schedule {schedule.name!r}") + + +def main(): + import sys + + run(usage(sys.argv[1:])) + + +if __name__ == "__main__": + main() diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index 660bd12b6..17fcc3dff 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -55,7 +55,7 @@ class SubscriptionItem: >>> server.subscriptions.create(sub) """ - def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None: + def __init__(self, subject: str, schedule_id: str | None, user_id: str, target: "Target") -> None: self._id = None self.attach_image = True self.attach_pdf = False @@ -194,6 +194,15 @@ def refresh_extract_triggered(self) -> bool: convert a time-based subscription into an extract-refresh-triggered one, issue two updates: first change ``schedule_id``, then set ``refresh_extract_triggered = True`` on a second call. + + **Manual-build update() footgun:** every ``subscriptions.update()`` + payload now carries ``refreshExtractTriggered="true"`` or + ``"false"``. The safe pattern is fetch-then-mutate-then-update, so + the value round-trips through the parser. If instead you build a + fresh ``SubscriptionItem`` locally, assign ``_id`` yourself, and + call ``update()``, the default False on the new item will flip an + existing extract-refresh-triggered subscription off on the server. + Fetch first. """ return self._refresh_extract_triggered diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index 6360ebf14..8bfd71b6f 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -47,7 +47,10 @@ def create(self, subscription_item: SubscriptionItem) -> SubscriptionItem: # See tableau/server-client-python#1658: users trying to create an # "On Extract Refresh" subscription pass schedule_id=None and hit # a confusing wire-layer error. Point them at the factory. - raise ValueError("schedule_id is required; see SubscriptionItem.on_extract_refresh") + raise ValueError( + "schedule_id is required; for on-extract-refresh subscriptions " + "use SubscriptionItem.on_extract_refresh(...)" + ) logger.info(f"Creating a subscription ({subscription_item})") url = self.baseurl create_req = RequestFactory.Subscription.create_req(subscription_item) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index c2dabc330..91acbd218 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1349,7 +1349,10 @@ def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt if subscription_item.send_if_view_empty is not None: content_element.attrib["sendIfViewEmpty"] = str(subscription_item.send_if_view_empty).lower() - # Schedule element + # Schedule element. schedule_id can be None on items parsed from + # inline-schedule responses; subscriptions.create() guards against + # that before we get here, so the value is non-None at this point. + assert subscription_item.schedule_id is not None schedule_element = ET.SubElement(subscription_element, "schedule") schedule_element.attrib["id"] = subscription_item.schedule_id diff --git a/test/test_subscription.py b/test/test_subscription.py index 045943aac..991d67b84 100644 --- a/test/test_subscription.py +++ b/test/test_subscription.py @@ -116,7 +116,7 @@ def test_create_rejects_none_schedule_id(server: TSC.Server) -> None: the inline-schedule form (no schedule id on the wire). """ target = TSC.Target("view-id", "view") - sub = TSC.SubscriptionItem("subject", None, "user-id", target) # type: ignore[arg-type] + sub = TSC.SubscriptionItem("subject", None, "user-id", target) with pytest.raises(ValueError, match="on_extract_refresh"): server.subscriptions.create(sub) @@ -217,7 +217,7 @@ def test_update_rejects_missing_schedule_id(server: TSC.Server) -> None: target = TSC.Target("view-id", "view") sub = TSC.SubscriptionItem("subject", "sched-id", "user-id", target) sub._id = "existing-sub-id" # type: ignore[assignment] - sub.schedule_id = None # type: ignore[assignment] + sub.schedule_id = None with pytest.raises(ValueError, match="schedule_id is required"): server.subscriptions.update(sub) From 04e4235267f81babd1b002d7a2ad8531f33afb74 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 30 Jul 2026 15:29:40 -0700 Subject: [PATCH 10/15] samples: add shared credential resolver that avoids the command line Introduces samples/_shared.py with resolve_credentials(args), which fills missing sign-in values from env vars (TABLEAU_SERVER, TABLEAU_TOKEN_NAME, etc.) or a .env-style file, and falls back to interactive getpass so secrets never touch shell history. CLI args still work for CI use. Wires the new helper into login.py, publish_workbook.py, and publish_datasource.py to establish the pattern; the remaining samples still accept the same CLI args and continue to work as before. Addresses tableau/server-client-python#1551 item 1. --- samples/_shared.py | 201 ++++++++++++++++++++++++++++++++++ samples/login.py | 73 +++--------- samples/publish_datasource.py | 42 ++----- samples/publish_workbook.py | 21 ++-- 4 files changed, 233 insertions(+), 104 deletions(-) create mode 100644 samples/_shared.py diff --git a/samples/_shared.py b/samples/_shared.py new file mode 100644 index 000000000..e728c227c --- /dev/null +++ b/samples/_shared.py @@ -0,0 +1,201 @@ +#### +# Shared helpers for the sample scripts in this directory. +# +# The most important thing here is `resolve_credentials`, which lets samples +# accept a Tableau server URL, site, and credentials from three sources: +# +# 1. Command-line arguments (useful for CI, but note that these end up in +# shell history and process listings, so avoid them for real secrets). +# 2. Environment variables. If a `.env` file exists next to the sample +# being run, or in the current working directory, we load it first -- +# only the standard `KEY=value` lines, no external dependency required. +# 3. Interactive prompts. Missing values are asked for on stdin; secrets +# are read with `getpass.getpass` so they are not echoed. +# +# CLI args take precedence, then environment, then interactive prompt. +# This lets a user set defaults in a `.env` file and override individual +# values on the command line. +#### + +from __future__ import annotations + +import argparse +import getpass +import os +from pathlib import Path +from typing import Iterable + +import tableauserverclient as TSC + +# Recognized environment variable names, in the order we look them up. +# Older samples used TABLEAU_SERVER etc; keep those working as aliases. +_ENV_ALIASES: dict[str, tuple[str, ...]] = { + "server": ("TABLEAU_SERVER", "SERVER"), + "site": ("TABLEAU_SITE", "SITE"), + "token_name": ("TABLEAU_TOKEN_NAME", "TOKEN_NAME"), + "token_value": ("TABLEAU_TOKEN_VALUE", "TOKEN_VALUE"), + "username": ("TABLEAU_USERNAME", "USERNAME"), + "password": ("TABLEAU_PASSWORD", "PASSWORD"), +} + + +def add_common_arguments(parser: argparse.ArgumentParser) -> None: + """Add the sign-in and logging arguments used by every sample. + + Kept in sync with the historical inline definitions so no existing + command line breaks. All arguments are optional -- missing values + are pulled from the environment or prompted for interactively. + """ + parser.add_argument("--server", "-s", help="server address (env: TABLEAU_SERVER)") + parser.add_argument("--site", "-S", help="site content URL (env: TABLEAU_SITE)") + parser.add_argument( + "--token-name", + "-p", + help="name of the personal access token used to sign into the server " "(env: TABLEAU_TOKEN_NAME)", + ) + parser.add_argument( + "--token-value", + "-v", + help="value of the personal access token used to sign into the server " + "(env: TABLEAU_TOKEN_VALUE). Prefer the env var or interactive prompt over the " + "command line so the secret does not land in shell history.", + ) + parser.add_argument( + "--username", + help="username to sign into the server (env: TABLEAU_USERNAME). Only used if " + "no personal access token is supplied.", + ) + parser.add_argument( + "--password", + help="password (env: TABLEAU_PASSWORD). Prefer the env var or interactive " "prompt over the command line.", + ) + parser.add_argument( + "--env-file", + help="path to a .env-style file with KEY=value lines to load. If omitted, " + ".env in the current directory is loaded automatically when present.", + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) + + +def _load_env_file(path: Path) -> None: + """Very small `.env` loader: `KEY=value` per line, `#` for comments. + + We do not want a runtime dependency on python-dotenv for the samples, + so this parses just the common cases. Existing env vars are not + overwritten -- a value already in `os.environ` wins. + """ + try: + text = path.read_text(encoding="utf-8") + except OSError: + return + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip("'\"") + if key and key not in os.environ: + os.environ[key] = value + + +def _first_env(names: Iterable[str]) -> str | None: + for name in names: + val = os.environ.get(name) + if val: + return val + return None + + +def resolve_credentials(args: argparse.Namespace, *, allow_prompt: bool = True) -> None: + """Fill in server/site/credential values on `args` from env or prompt. + + Precedence for each field: existing value on `args` > environment variable + > interactive prompt (if allow_prompt and stdin is a terminal). + + Pass `allow_prompt=False` in CI environments where blocking on input would + hang the job; the caller should then verify the fields it needs are set. + """ + # Load `.env` file if one is requested or available. + env_file = getattr(args, "env_file", None) + if env_file: + _load_env_file(Path(env_file)) + else: + default_env = Path.cwd() / ".env" + if default_env.is_file(): + _load_env_file(default_env) + + # For each field, prefer the CLI arg, then env, then prompt. + for field, env_names in _ENV_ALIASES.items(): + current = getattr(args, field, None) + if current: + continue + env_val = _first_env(env_names) + if env_val: + setattr(args, field, env_val) + + if not allow_prompt: + return + + # Prompt for what's still missing. We only prompt for the pieces we + # actually need: server URL, and one of token or username/password. + if not getattr(args, "server", None): + args.server = input("Tableau server URL: ").strip() + + # Site is optional (empty string is the default site) so we don't prompt. + + has_token = getattr(args, "token_name", None) and getattr(args, "token_value", None) + has_user = getattr(args, "username", None) and getattr(args, "password", None) + + if has_token or has_user: + return + + # Nothing configured yet. Ask which auth method to use. + if getattr(args, "token_name", None) or getattr(args, "username", None): + # Partial info supplied -- fill in the matching missing piece. + if getattr(args, "token_name", None) and not getattr(args, "token_value", None): + args.token_value = getpass.getpass(f"Personal access token value for '{args.token_name}': ") + return + if getattr(args, "username", None) and not getattr(args, "password", None): + args.password = getpass.getpass(f"Password for '{args.username}': ") + return + + # Fully unspecified: default to PAT since that's what the docs recommend. + print("No credentials found in args or environment. Sign in with a personal access token.") + print("(Set TABLEAU_TOKEN_NAME / TABLEAU_TOKEN_VALUE in your env or a .env file to skip this prompt.)") + args.token_name = input("Personal access token name: ").strip() + args.token_value = getpass.getpass("Personal access token value: ") + + +def build_auth(args: argparse.Namespace) -> TSC.TableauAuth | TSC.PersonalAccessTokenAuth: + """Return the appropriate auth object based on what's set on `args`.""" + site = getattr(args, "site", None) or "" + if getattr(args, "token_name", None) and getattr(args, "token_value", None): + return TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=site) + if getattr(args, "username", None) and getattr(args, "password", None): + return TSC.TableauAuth(args.username, args.password, site_id=site) + raise ValueError( + "No usable credentials found. Provide --token-name/--token-value, " + "--username/--password, or set the corresponding env vars." + ) + + +def sign_in(args: argparse.Namespace, *, use_server_version: bool = True) -> TSC.Server: + """Convenience helper: resolve credentials, build the server, and sign in. + + The caller is responsible for calling `server.auth.sign_out()` or using + the `with server.auth.sign_in(...)` context manager pattern themselves + when they need finer control. This helper is intended for the small + samples that just want a signed-in server object to poke at. + """ + resolve_credentials(args) + auth = build_auth(args) + server = TSC.Server(args.server, use_server_version=use_server_version) + server.auth.sign_in(auth) + return server diff --git a/samples/login.py b/samples/login.py index bc99385b3..fb339e4e5 100644 --- a/samples/login.py +++ b/samples/login.py @@ -2,82 +2,43 @@ # This script demonstrates how to log in to Tableau Server Client. # # To run the script, you must have installed Python 3.7 or later. +# +# Credentials can be supplied on the command line, from environment variables +# (TABLEAU_SERVER, TABLEAU_SITE, TABLEAU_TOKEN_NAME, TABLEAU_TOKEN_VALUE, +# TABLEAU_USERNAME, TABLEAU_PASSWORD), from a `.env` file in the current +# working directory, or interactively via getpass. Prefer env or a .env file +# over CLI args so secrets do not end up in your shell history. #### import argparse -import getpass import logging -import os import tableauserverclient as TSC - -def get_env(key): - if key in os.environ: - return os.environ[key] - return None +from _shared import add_common_arguments, build_auth, resolve_credentials # If a sample has additional arguments, then it should copy this code and insert them after the call to -# sample_define_common_options -# If it has no additional arguments, it can just call this method +# add_common_arguments. If it has no additional arguments, it can just call this method. def set_up_and_log_in(): parser = argparse.ArgumentParser(description="Logs in to the server.") - sample_define_common_options(parser) + add_common_arguments(parser) args = parser.parse_args() - if not args.server: - args.server = get_env("SERVER") - if not args.site: - args.site = get_env("SITE") - if not args.token_name: - args.token_name = get_env("TOKEN_NAME") - if not args.token_value: - args.token_value = get_env("TOKEN_VALUE") - args.logging_level = "debug" + + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) server = sample_connect_to_server(args) print(server.server_info.get()) print(server.server_address, "site:", server.site_id, "user:", server.user_id) -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", help="server address") - parser.add_argument("--site", "-t", help="site name") - 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") - - parser.add_argument("--token-value", "-tv", help="value of the personal access token used to sign into the server") - parser.add_argument("--password", "-p", help="value of the password 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)", - ) - - def sample_connect_to_server(args): - if args.username: - # Trying to authenticate using username and password. - password = args.password or getpass.getpass("Password: ") - - tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.site) - print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nUsername: {args.username}") - - else: - # Trying to authenticate using personal access tokens. - token = args.token_value or getpass.getpass("Personal Access Token: ") - - tableau_auth = TSC.PersonalAccessTokenAuth( - token_name=args.token_name, personal_access_token=token, site_id=args.site - ) + tableau_auth = build_auth(args) + if isinstance(tableau_auth, TSC.PersonalAccessTokenAuth): print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nToken name: {args.token_name}") - - if not tableau_auth: - raise TabError("Did not create authentication object. Check arguments.") + else: + print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nUsername: {args.username}") # Only set this to False if you are running against a server you trust AND you know why the cert is broken check_ssl_certificate = True @@ -85,8 +46,6 @@ def sample_connect_to_server(args): # Make sure we use an updated version of the rest apis, and pass in our cert handling choice server = TSC.Server(args.server, use_server_version=True, http_options={"verify": check_ssl_certificate}) server.auth.sign_in(tableau_auth) - server.version = "3.19" - return server diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index c674e6882..772c436f1 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -21,31 +21,17 @@ import argparse import logging -import os import tableauserverclient as TSC import tableauserverclient.datetime_helpers - -def get_env(key): - if key in os.environ: - return os.environ[key] - return None +from _shared import add_common_arguments, build_auth, resolve_credentials 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", 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)", - ) + # Common options -- credentials come from CLI args, env vars, a .env file, + # or an interactive prompt. See samples/_shared.py. + add_common_arguments(parser) # Options specific to this sample parser.add_argument("--file", "-f", help="filepath to the datasource to publish") parser.add_argument("--project", help="Project within which to publish the datasource") @@ -56,30 +42,18 @@ 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 = get_env("SERVER") - if not args.site: - args.site = get_env("SITE") - if not args.token_name: - args.token_name = get_env("TOKEN_NAME") - if not args.token_value: - args.token_value = get_env("TOKEN_VALUE") - args.logging = "debug" - args.file = "C:/dev/tab-samples/5M.tdsx" - args.async_ = True + + resolve_credentials(args) # 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 - - _logger = logging.getLogger(__name__) - _logger.setLevel(logging.DEBUG) - _logger.addHandler(logging.StreamHandler()) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) # Sign in to server - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + tableau_auth = build_auth(args) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): # Empty project_id field will default the publish to the site's default project diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 077ddaddd..5efd18e54 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -20,21 +20,14 @@ import tableauserverclient as TSC from tableauserverclient import ConnectionCredentials, ConnectionItem +from _shared import add_common_arguments, build_auth, resolve_credentials + 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", 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)", - ) + # Common options -- credentials come from CLI args, env vars, a .env file, + # or an interactive prompt. See samples/_shared.py. + add_common_arguments(parser) # Options specific to this sample group = parser.add_mutually_exclusive_group(required=False) group.add_argument("--thumbnails-user-id", "-u", help="User ID to use for thumbnails") @@ -49,12 +42,14 @@ def main(): args = parser.parse_args() + resolve_credentials(args) + # Set logging level based on user input, or error by default logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) # Step 1: Sign in to server. - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + tableau_auth = build_auth(args) server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False}) with server.auth.sign_in(tableau_auth): # Step2: Retrieve the project id, if a project name was passed From aa1d6d9ef6cdb197e37a5528e8b1fe7139dee7e4 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 30 Jul 2026 15:31:54 -0700 Subject: [PATCH 11/15] samples: fix mispagination in samples that treated a single page as all Several samples called `server..get()` and named the result `all_workbooks`, `all_datasources`, etc. This only returns the first page (default 100 items); if the item of interest was not on that page it was silently missed and the sample failed with a "not found" message. Replace those calls with `TSC.Pager(server.)` so every page is walked. Where a total count was being displayed we still make one plain `.get()` up front so the total_available field is available without paging through the whole site twice. Also corrects an unrelated typo in getting_started/3_hello_universe.py where the "workbooks" section actually queried datasources. Addresses tableau/server-client-python#1551 item 2 (and #1531). --- samples/explore_datasource.py | 14 +++++++++----- samples/explore_favorites.py | 7 ++++--- samples/explore_webhooks.py | 6 ++++-- samples/explore_workbook.py | 17 ++++++++++------- samples/extracts.py | 6 ++++-- samples/getting_started/3_hello_universe.py | 2 +- samples/move_workbook_sites.py | 6 +++--- samples/publish_workbook.py | 7 +++++-- samples/refresh_tasks.py | 4 ++-- .../update_workbook_data_freshness_policy.py | 6 ++++-- 10 files changed, 46 insertions(+), 29 deletions(-) diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index c9f35d5be..9938aaf09 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -43,9 +43,10 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - # Query projects for use when demonstrating publishing and updating - all_projects, pagination_item = server.projects.get() - default_project = next((project for project in all_projects if project.is_default()), None) + # Query projects for use when demonstrating publishing and updating. + # Use TSC.Pager (or `.all()` / `.filter()`) to iterate every page; + # a raw `server.projects.get()` only returns the first page. + default_project = next((project for project in TSC.Pager(server.projects) if project.is_default()), None) # Publish datasource if publish flag is set (-publish, -p) if args.publish: @@ -59,9 +60,12 @@ def main(): else: print("Publish failed. Could not find the default project.") - # Gets all datasource items - all_datasources, pagination_item = server.datasources.get() + # Gets all datasource items. `.get()` returns only one page; use + # TSC.Pager to iterate every page. The first response also gives us + # the total_available count without paging through everything. + first_page, pagination_item = server.datasources.get() print(f"\nThere are {pagination_item.total_available} datasources on site: ") + all_datasources = list(TSC.Pager(server.datasources)) print([datasource.name for datasource in all_datasources]) if all_datasources: diff --git a/samples/explore_favorites.py b/samples/explore_favorites.py index f199522ed..8358f8d6e 100644 --- a/samples/explore_favorites.py +++ b/samples/explore_favorites.py @@ -43,8 +43,9 @@ def main(): server.favorites.get(user) print(user.favorites) - # get list of workbooks - all_workbook_items, pagination_item = server.workbooks.get() + # get list of workbooks. `.get()` only returns one page; use + # TSC.Pager to iterate every workbook on the site. + all_workbook_items = list(TSC.Pager(server.workbooks)) if all_workbook_items is not None and len(all_workbook_items) > 0: my_workbook = all_workbook_items[0] server.favorites.add_favorite(user, Resource.Workbook, all_workbook_items[0]) @@ -59,7 +60,7 @@ def main(): server.favorites.add_favorite_view(user, my_view) print(f"View added to favorites. View Name: {my_view.name}, View ID: {my_view.id}") - all_datasource_items, pagination_item = server.datasources.get() + all_datasource_items = list(TSC.Pager(server.datasources)) if all_datasource_items: my_datasource = all_datasource_items[0] server.favorites.add_favorite_datasource(user, my_datasource) diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index f25c41849..129cc6bbf 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -54,9 +54,11 @@ def main(): new_webhook = server.webhooks.create(new_webhook) print(f"Webhook created. ID: {new_webhook.id}") - # Gets all webhook items - all_webhooks, pagination_item = server.webhooks.get() + # Gets all webhook items. `.get()` returns only one page; use + # TSC.Pager to iterate every webhook on the site. + first_page, pagination_item = server.webhooks.get() print(f"\nThere are {pagination_item.total_available} webhooks on site: ") + all_webhooks = list(TSC.Pager(server.webhooks)) print([webhook.name for webhook in all_webhooks]) if all_webhooks: diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index d537f21d6..10af0902d 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -53,8 +53,9 @@ def main(): # Publish workbook if publish flag is set (-publish, -p) overwrite_true = TSC.Server.PublishMode.Overwrite if args.publish: - all_projects, pagination_item = server.projects.get() - default_project = next((project for project in all_projects if project.is_default()), None) + # Use TSC.Pager rather than a raw `.get()` because `.get()` only + # returns the first page of results. + default_project = next((project for project in TSC.Pager(server.projects) if project.is_default()), None) if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) @@ -63,9 +64,11 @@ def main(): else: print("Publish failed. Could not find the default project.") - # Gets all workbook items - all_workbooks, pagination_item = server.workbooks.get() + # Gets all workbook items. Note that `.get()` only returns the first + # page of results; use TSC.Pager to iterate every page. + first_page, pagination_item = server.workbooks.get() print(f"\nThere are {pagination_item.total_available} workbooks on site: ") + all_workbooks = list(TSC.Pager(server.workbooks)) print([workbook.name for workbook in all_workbooks]) if all_workbooks: @@ -123,9 +126,9 @@ def main(): f.write(sample_workbook.preview_image) print(f"\nDownloaded preview image of workbook to {os.path.abspath(args.preview_image)}") - # get custom views - cvs, _ = server.custom_views.get() - for c in cvs: + # Get custom views. `.get()` only returns the first page; + # use TSC.Pager to iterate every custom view on the site. + for c in TSC.Pager(server.custom_views): print(c) # for the last custom view in the list diff --git a/samples/extracts.py b/samples/extracts.py index d9289452a..1518adb01 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -53,9 +53,11 @@ def main(): if ds is None: raise ValueError(f"Datasource not found for id {args.datasource}") else: - # Gets all workbook items - all_workbooks, pagination_item = server.workbooks.get() + # Gets all workbook items. `.get()` returns only the first page, + # so we use TSC.Pager to iterate every page. + first_page, pagination_item = server.workbooks.get() print(f"\nThere are {pagination_item.total_available} workbooks on site: ") + all_workbooks = list(TSC.Pager(server.workbooks)) print([workbook.name for workbook in all_workbooks]) if all_workbooks: diff --git a/samples/getting_started/3_hello_universe.py b/samples/getting_started/3_hello_universe.py index a2c4301d0..057298b4d 100644 --- a/samples/getting_started/3_hello_universe.py +++ b/samples/getting_started/3_hello_universe.py @@ -40,7 +40,7 @@ def main(): for project in projects: print(project.name) - workbooks, pagination = server.datasources.get() + workbooks, pagination = server.workbooks.get() if workbooks: print(f"{pagination.total_available} workbooks") print(workbooks[0]) diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index e82c75cf9..39cc4ba93 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -65,10 +65,10 @@ def main(): try: workbook_path = source_server.workbooks.download(all_workbooks[0].id, tmpdir) - # Step 4: Check if destination site exists, then sign in to the site - all_sites, pagination_info = source_server.sites.get() + # Step 4: Check if destination site exists, then sign in to the site. + # Use TSC.Pager because `.get()` only returns the first page of sites. found_destination_site = any( - True for site in all_sites if args.destination_site.lower() == site.content_url.lower() + args.destination_site.lower() == site.content_url.lower() for site in TSC.Pager(source_server.sites) ) if not found_destination_site: error = f"No site named {args.destination_site} found." diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 5efd18e54..7d3bc6b51 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -64,8 +64,11 @@ def main(): project_id = projects[0].id else: # Get all the projects on server, then look for the default one. - all_projects, pagination_item = server.projects.get() - project_id = next((project for project in all_projects if project.is_default()), None).id + # Use TSC.Pager because `.get()` only returns the first page. + default_project = next((project for project in TSC.Pager(server.projects) if project.is_default()), None) + if default_project is None: + raise LookupError("The destination project could not be found.") + project_id = default_project.id connection1 = ConnectionItem() connection1.server_address = "mssql.test.com" diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index c95000898..16effb004 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -17,8 +17,8 @@ def handle_run(server, args): def handle_list(server, _): - tasks, pagination = server.tasks.get() - for task in tasks: + # Use TSC.Pager to iterate every task; `.get()` returns only the first page. + for task in TSC.Pager(server.tasks): print(f"{task}") diff --git a/samples/update_workbook_data_freshness_policy.py b/samples/update_workbook_data_freshness_policy.py index c23e3717f..9eee2fe75 100644 --- a/samples/update_workbook_data_freshness_policy.py +++ b/samples/update_workbook_data_freshness_policy.py @@ -43,9 +43,11 @@ def main(): server.add_http_options({"verify": False}) server.use_server_version() with server.auth.sign_in(tableau_auth): - # Get workbook - all_workbooks, pagination_item = server.workbooks.get() + # Get workbooks. `.get()` only returns the first page; iterate with + # TSC.Pager to see every workbook on the site. + first_page, pagination_item = server.workbooks.get() print(f"\nThere are {pagination_item.total_available} workbooks on site: ") + all_workbooks = list(TSC.Pager(server.workbooks)) print([workbook.name for workbook in all_workbooks]) if all_workbooks: From 92b1d36bc9fd65d6ad1b3c44a2856b114ec6f34f Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 30 Jul 2026 15:34:42 -0700 Subject: [PATCH 12/15] samples: add list_jobs and manage_subscriptions for coverage gaps The existing samples cover workbooks, datasources, schedules, extracts, projects, users, groups, favorites, and webhooks, but there was no sample for two frequently asked-about endpoints: * list_jobs.py -- lists background jobs (extract refreshes, publishes, flow runs, etc.), demonstrating the .filter() queryset with date/status/type filters and the wait_for_job helper. * manage_subscriptions.py -- list/create/delete site subscriptions, demonstrating the SubscriptionItem + Target pattern and paginated listing with TSC.Pager. Both samples use the new samples/_shared.py credential resolver so the sign-in pattern matches the rest of the samples. Addresses tableau/server-client-python#1551 item 3. --- samples/list_jobs.py | 134 ++++++++++++++++++++++++++++++++ samples/manage_subscriptions.py | 121 ++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 samples/list_jobs.py create mode 100644 samples/manage_subscriptions.py diff --git a/samples/list_jobs.py b/samples/list_jobs.py new file mode 100644 index 000000000..e1835abf4 --- /dev/null +++ b/samples/list_jobs.py @@ -0,0 +1,134 @@ +#### +# This script demonstrates how to list background jobs on a Tableau site +# and (optionally) wait for a specific job to finish. +# +# Background jobs are created when you run an extract refresh, publish +# asynchronously, run a flow, delete a site asynchronously, and so on. +# See the REST API "Query Jobs" reference for the full list of job types. +# +# Examples: +# +# # List every job on the site, most recent first. +# python samples/list_jobs.py +# +# # Only jobs from the last 24 hours. +# python samples/list_jobs.py --hours 24 +# +# # Only in-progress refresh_extracts jobs. +# python samples/list_jobs.py --status InProgress --type refresh_extracts +# +# # Wait for a specific job to finish. +# python samples/list_jobs.py --wait +# +# To run the script, you must have installed Python 3.9 or later. +#### + +import argparse +import datetime +import logging + +import tableauserverclient as TSC +from tableauserverclient.server.endpoint.exceptions import JobCancelledException, JobFailedException + +from _shared import add_common_arguments, build_auth, resolve_credentials + + +def main(): + parser = argparse.ArgumentParser(description="List background jobs on the site, or wait for one to finish.") + add_common_arguments(parser) + + parser.add_argument( + "--hours", + type=int, + help="Only show jobs created in the last N hours (uses the filter endpoint).", + ) + parser.add_argument( + "--status", + help="Filter by job status, e.g. Success, Failed, InProgress, Cancelled, Pending.", + ) + parser.add_argument( + "--type", + dest="job_type", + help="Filter by job type, e.g. refresh_extracts, publish, run_flow.", + ) + parser.add_argument( + "--wait", + metavar="JOB_ID", + help="Instead of listing, wait for the given job ID to complete and print the result.", + ) + parser.add_argument( + "--timeout", + type=float, + help="Max seconds to wait when --wait is used. Defaults to no timeout.", + ) + + args = parser.parse_args() + + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) + + tableau_auth = build_auth(args) + server = TSC.Server(args.server, use_server_version=True) + + with server.auth.sign_in(tableau_auth): + if args.wait: + _wait_for_job(server, args.wait, args.timeout) + return + + _list_jobs(server, args) + + +def _list_jobs(server, args): + """List jobs using the queryset filter API, which handles pagination for us.""" + + # `server.jobs.filter(...)` returns a QuerySet that is directly iterable + # and pages through the server automatically. This is the recommended + # way to iterate every job on the site -- do NOT use a raw + # `server.jobs.get()`, which only returns the first page. + query = server.jobs.filter() + + if args.hours is not None: + cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=args.hours) + # Filter operator suffixes: __gt / __gte / __lt / __lte / __in / __has + # See tableauserverclient.server.query.QuerySet for the full list. + query = query.filter(created_at__gte=cutoff.isoformat()) + + if args.status: + query = query.filter(status=args.status) + + if args.job_type: + query = query.filter(job_type=args.job_type) + + # Newest first is usually what a human wants when scanning. + query = query.order_by("-created_at") + + printed = 0 + for job in query: + # BackgroundJobItem fields: id, type, status, created_at, started_at, ended_at, ... + print( + f"{job.id} {job.type or '-':<24} {job.status or '-':<12} " + f"created={job.created_at} ended={job.ended_at}" + ) + printed += 1 + + if printed == 0: + print("No jobs matched the given filters.") + + +def _wait_for_job(server, job_id, timeout): + """Poll a single job until it finishes, using the built-in helper.""" + try: + job = server.jobs.wait_for_job(job_id, timeout=timeout) + except JobFailedException as exc: + # The exception carries the failed JobItem so callers can inspect it. + print(f"Job {job_id} failed: notes={exc.job.notes}") + raise SystemExit(1) from exc + except JobCancelledException: + print(f"Job {job_id} was cancelled.") + raise SystemExit(2) + + print(f"Job {job_id} finished. finish_code={job.finish_code} notes={job.notes}") + + +if __name__ == "__main__": + main() diff --git a/samples/manage_subscriptions.py b/samples/manage_subscriptions.py new file mode 100644 index 000000000..5d8bc70e8 --- /dev/null +++ b/samples/manage_subscriptions.py @@ -0,0 +1,121 @@ +#### +# This script demonstrates how to list, create, and delete subscriptions +# on a Tableau site. +# +# A subscription pairs a user, a schedule, and a target (workbook or view); +# the user is emailed a snapshot of the target on each schedule tick. +# See the REST API "Subscriptions" reference for full details. +# +# Examples: +# +# # List every subscription on the site. +# python samples/manage_subscriptions.py list +# +# # Create a subscription for the signed-in user against a view + schedule. +# python samples/manage_subscriptions.py create \ +# --target-type view \ +# --target-id \ +# --schedule-id \ +# --subject "Daily sales snapshot" +# +# # Delete an existing subscription. +# python samples/manage_subscriptions.py delete --id +# +# To run the script, you must have installed Python 3.9 or later. +#### + +import argparse +import logging + +import tableauserverclient as TSC + +from _shared import add_common_arguments, build_auth, resolve_credentials + + +def handle_list(server, args): + """List every subscription on the site, iterating every page.""" + # `server.subscriptions.get()` returns only the first page. Pass the + # endpoint to TSC.Pager to iterate every subscription without hand- + # rolling pagination logic. + count = 0 + for sub in TSC.Pager(server.subscriptions): + print( + f"{sub.id} subject={sub.subject!r} " + f"user_id={sub.user_id} schedule_id={sub.schedule_id} target={sub.target}" + ) + count += 1 + if count == 0: + print("No subscriptions found on this site.") + + +def handle_create(server, args): + """Create a new subscription for the signed-in user (unless --user-id given).""" + user_id = args.user_id or server.user_id + if not user_id: + raise SystemExit("Could not determine user_id. Pass --user-id or ensure sign-in succeeded.") + + # The REST API expects lowercase content types ("workbook" or "view"). + target = TSC.Target(args.target_id, args.target_type.lower()) + new_sub = TSC.SubscriptionItem( + subject=args.subject, + schedule_id=args.schedule_id, + user_id=user_id, + target=target, + ) + if args.message: + new_sub.message = args.message + new_sub.attach_image = args.attach_image + new_sub.attach_pdf = args.attach_pdf + + created = server.subscriptions.create(new_sub) + print(f"Created subscription {created.id} for user {created.user_id} against {created.target}") + + +def handle_delete(server, args): + """Delete a subscription by ID.""" + server.subscriptions.delete(args.id) + print(f"Deleted subscription {args.id}.") + + +def main(): + parser = argparse.ArgumentParser(description="List, create, and delete Tableau subscriptions.") + add_common_arguments(parser) + + subcommands = parser.add_subparsers(dest="command", required=True) + + list_p = subcommands.add_parser("list", help="List every subscription on the site.") + list_p.set_defaults(func=handle_list) + + create_p = subcommands.add_parser("create", help="Create a new subscription.") + create_p.add_argument("--target-type", required=True, choices=["Workbook", "View", "workbook", "view"]) + create_p.add_argument("--target-id", required=True, help="ID of the workbook or view to subscribe to.") + create_p.add_argument( + "--schedule-id", required=True, help="ID of the schedule to attach to (see create_schedules.py)." + ) + create_p.add_argument("--subject", required=True, help="Email subject line.") + create_p.add_argument("--message", help="Optional email body message.") + create_p.add_argument( + "--user-id", + help="User to subscribe. Defaults to the signed-in user.", + ) + create_p.add_argument("--attach-image", action="store_true", default=True, help="Attach a PNG snapshot (default).") + create_p.add_argument("--attach-pdf", action="store_true", default=False, help="Also attach a PDF snapshot.") + create_p.set_defaults(func=handle_create) + + delete_p = subcommands.add_parser("delete", help="Delete a subscription by ID.") + delete_p.add_argument("--id", required=True, help="Subscription ID to delete.") + delete_p.set_defaults(func=handle_delete) + + args = parser.parse_args() + + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) + + tableau_auth = build_auth(args) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(tableau_auth): + args.func(server, args) + + +if __name__ == "__main__": + main() From e3b9d85ec1b7d9e7381b07a294024d362cddc44a Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Sat, 8 Aug 2026 01:03:13 -0700 Subject: [PATCH 13/15] samples: align sign-in short flags with tabcmd Restore -t for --site, -u for --username, -p for --password; drop short flags on --token-name and --token-value. This matches tabcmd's canonical short flags in tabcmd/execution/parent_parser.py so users running both tools have one convention to remember. The initial refactor picked new short flags without noticing that the old samples/login.py already followed tabcmd's convention (-p was --password, -t was --site). Reassigning -p to --token-name meant `python login.py -p ` silently sent the password as a token name. Co-Authored-By: Claude Opus 4.7 (1M context) --- samples/_shared.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/samples/_shared.py b/samples/_shared.py index e728c227c..2bbdad3dc 100644 --- a/samples/_shared.py +++ b/samples/_shared.py @@ -47,26 +47,26 @@ def add_common_arguments(parser: argparse.ArgumentParser) -> None: are pulled from the environment or prompted for interactively. """ parser.add_argument("--server", "-s", help="server address (env: TABLEAU_SERVER)") - parser.add_argument("--site", "-S", help="site content URL (env: TABLEAU_SITE)") + parser.add_argument("--site", "-t", help="site content URL (env: TABLEAU_SITE)") parser.add_argument( "--token-name", - "-p", help="name of the personal access token used to sign into the server " "(env: TABLEAU_TOKEN_NAME)", ) parser.add_argument( "--token-value", - "-v", help="value of the personal access token used to sign into the server " "(env: TABLEAU_TOKEN_VALUE). Prefer the env var or interactive prompt over the " "command line so the secret does not land in shell history.", ) parser.add_argument( "--username", + "-u", help="username to sign into the server (env: TABLEAU_USERNAME). Only used if " "no personal access token is supplied.", ) parser.add_argument( "--password", + "-p", help="password (env: TABLEAU_PASSWORD). Prefer the env var or interactive " "prompt over the command line.", ) parser.add_argument( From e9a11fa32294c481d9e98c43bfcce97fc232df0f Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 18 Aug 2026 02:58:07 -0700 Subject: [PATCH 14/15] samples: fix argparse blocker + 7 bugs, add JWT + on-extract-refresh Round of fixes for the sample-scripts refactor after fresh-eyes review. Blocker: publish_workbook.py reused `-u` for --thumbnails-user-id while _shared.py add_common_arguments already binds `-u` to --username, so argparse raised ArgumentError on module load and the script would not start. Renamed to `-U`. Real bugs: - _shared.py .env search now checks cwd, samples/, and repo root (in that order) so the docstring stops lying about "next to the sample or cwd." - resolve_credentials now gates input()/getpass on sys.stdin.isatty() as the docstring already promised, so piped/CI invocations no longer hang forever. - manage_subscriptions.py --attach-image switched to argparse.BooleanOptionalAction so users can actually pass --no-attach-image; the previous store_true+default=True made the flag a permanent True. - Header docstring in _shared.py no longer claims "no existing command line breaks" (which was false: -p migrated from --token-name to --password in an earlier commit). Documented the tabcmd-aligned short flags instead. - Corrected Python-version headers on login.py, list_jobs.py, manage_subscriptions.py, publish_workbook.py, refresh_tasks.py, move_workbook_sites.py, publish_datasource.py, and update_workbook_data_freshness_policy.py -- repo floor is 3.10 per pyproject.toml. - list_jobs._wait_for_job: reordered excepts so JobCancelledException (a subclass of JobFailedException) is caught first, otherwise cancelled jobs were reported as failed with the wrong exit code. - login.py sign-in banner now branches on JWTAuth as well, so JWT logins no longer print "Username: None". Header env-var list updated to include TABLEAU_JWT / TABLEAU_JWT_FILE. New JWT support: _shared.py add_common_arguments now exposes --jwt and --jwt-file, resolves TABLEAU_JWT / TABLEAU_JWT_FILE from env, reads a JWT file path into args.jwt during resolve_credentials, and returns TSC.JWTAuth from build_auth when a JWT is present. JWT takes priority over PAT and username/password. Extract-refresh subscription: manage_subscriptions.py create now accepts --on-extract-refresh, which calls SubscriptionItem.on_extract_refresh() to construct a subscription that fires when the referenced extract-refresh schedule completes (the flow introduced in #1861). Rebased this branch onto jac/subscription-refresh-extract-triggered so the flag lands on top of the new API without conflicts. Co-Authored-By: Claude Opus 4.7 (1M context) --- samples/_shared.py | 129 +++++++++++++----- samples/list_jobs.py | 11 +- samples/login.py | 18 ++- samples/manage_subscriptions.py | 70 ++++++++-- samples/move_workbook_sites.py | 2 +- samples/publish_datasource.py | 2 +- samples/publish_workbook.py | 6 +- samples/refresh_tasks.py | 2 +- .../update_workbook_data_freshness_policy.py | 2 +- 9 files changed, 181 insertions(+), 61 deletions(-) diff --git a/samples/_shared.py b/samples/_shared.py index 2bbdad3dc..7f13c90c0 100644 --- a/samples/_shared.py +++ b/samples/_shared.py @@ -6,15 +6,23 @@ # # 1. Command-line arguments (useful for CI, but note that these end up in # shell history and process listings, so avoid them for real secrets). -# 2. Environment variables. If a `.env` file exists next to the sample -# being run, or in the current working directory, we load it first -- -# only the standard `KEY=value` lines, no external dependency required. -# 3. Interactive prompts. Missing values are asked for on stdin; secrets -# are read with `getpass.getpass` so they are not echoed. +# 2. Environment variables. We look for a `.env` file in the current +# working directory, in the samples/ directory, and at the repository +# root, in that order, and load whichever we find first -- only the +# standard `KEY=value` lines, no external dependency required. +# 3. Interactive prompts. Missing values are asked for on stdin when +# stdin is a terminal; secrets are read with `getpass.getpass` so they +# are not echoed. In non-interactive contexts (CI, piped input) we skip +# the prompts and let `build_auth` raise instead of hanging on `input()`. # # CLI args take precedence, then environment, then interactive prompt. # This lets a user set defaults in a `.env` file and override individual # values on the command line. +# +# Sign-in short flags follow the tabcmd convention (-s server, -t site, +# -u username, -p password). --token-name and --token-value do not have +# short flags because tabcmd does not either and re-using a letter here +# would silently accept a token as a password on old command lines. #### from __future__ import annotations @@ -22,6 +30,7 @@ import argparse import getpass import os +import sys from pathlib import Path from typing import Iterable @@ -36,15 +45,21 @@ "token_value": ("TABLEAU_TOKEN_VALUE", "TOKEN_VALUE"), "username": ("TABLEAU_USERNAME", "USERNAME"), "password": ("TABLEAU_PASSWORD", "PASSWORD"), + "jwt": ("TABLEAU_JWT", "JWT"), + "jwt_file": ("TABLEAU_JWT_FILE", "JWT_FILE"), } def add_common_arguments(parser: argparse.ArgumentParser) -> None: """Add the sign-in and logging arguments used by every sample. - Kept in sync with the historical inline definitions so no existing - command line breaks. All arguments are optional -- missing values - are pulled from the environment or prompted for interactively. + Short flags follow the tabcmd convention: -s server, -t site, + -u username, -p password, -l logging-level. --token-name / + --token-value and --jwt / --jwt-file intentionally have no short + flag; re-using letters here risked silently accepting a token as + a password on scripts that pre-date the shared helper. All args + are optional; missing values are pulled from the environment or + prompted for interactively. """ parser.add_argument("--server", "-s", help="server address (env: TABLEAU_SERVER)") parser.add_argument("--site", "-t", help="site content URL (env: TABLEAU_SITE)") @@ -62,17 +77,28 @@ def add_common_arguments(parser: argparse.ArgumentParser) -> None: "--username", "-u", help="username to sign into the server (env: TABLEAU_USERNAME). Only used if " - "no personal access token is supplied.", + "no personal access token or JWT is supplied.", ) parser.add_argument( "--password", "-p", help="password (env: TABLEAU_PASSWORD). Prefer the env var or interactive " "prompt over the command line.", ) + parser.add_argument( + "--jwt", + help="encoded JSON Web Token for Connected-App sign-in (env: TABLEAU_JWT). " + "Mutually exclusive with token/username auth; see JWTAuth in the docs.", + ) + parser.add_argument( + "--jwt-file", + help="path to a file whose contents are the encoded JWT (env: TABLEAU_JWT_FILE). " + "Useful for pipelines that mint a JWT into a file rather than an env var.", + ) parser.add_argument( "--env-file", help="path to a .env-style file with KEY=value lines to load. If omitted, " - ".env in the current directory is loaded automatically when present.", + ".env is looked for in the current directory, the samples/ directory, and " + "the repository root, and the first one found is loaded.", ) parser.add_argument( "--logging-level", @@ -113,23 +139,39 @@ def _first_env(names: Iterable[str]) -> str | None: return None +def _candidate_env_paths() -> list[Path]: + """Locations we check for a .env file, in priority order. + + cwd first (so the invoker can override), then the directory that holds + this shared module (samples/), then the repository root one level up. + """ + module_dir = Path(__file__).resolve().parent + return [ + Path.cwd() / ".env", + module_dir / ".env", + module_dir.parent / ".env", + ] + + def resolve_credentials(args: argparse.Namespace, *, allow_prompt: bool = True) -> None: """Fill in server/site/credential values on `args` from env or prompt. Precedence for each field: existing value on `args` > environment variable - > interactive prompt (if allow_prompt and stdin is a terminal). + > interactive prompt (only when allow_prompt is true AND stdin is a TTY). - Pass `allow_prompt=False` in CI environments where blocking on input would - hang the job; the caller should then verify the fields it needs are set. + Pass `allow_prompt=False`, or run with stdin redirected (CI, piped input), + to skip the prompts entirely; the caller should then verify the fields it + needs are set, or let `build_auth` raise a clear ValueError. """ # Load `.env` file if one is requested or available. env_file = getattr(args, "env_file", None) if env_file: _load_env_file(Path(env_file)) else: - default_env = Path.cwd() / ".env" - if default_env.is_file(): - _load_env_file(default_env) + for candidate in _candidate_env_paths(): + if candidate.is_file(): + _load_env_file(candidate) + break # For each field, prefer the CLI arg, then env, then prompt. for field, env_names in _ENV_ALIASES.items(): @@ -140,31 +182,42 @@ def resolve_credentials(args: argparse.Namespace, *, allow_prompt: bool = True) if env_val: setattr(args, field, env_val) - if not allow_prompt: + # If a JWT file was provided, read its contents into args.jwt (unless the + # caller also passed --jwt directly, in which case the direct value wins). + jwt_file = getattr(args, "jwt_file", None) + if jwt_file and not getattr(args, "jwt", None): + try: + args.jwt = Path(jwt_file).read_text(encoding="utf-8").strip() + except OSError as exc: + raise SystemExit(f"Could not read --jwt-file {jwt_file!r}: {exc}") from exc + + # Skip prompting entirely if the caller opted out or stdin is not a + # terminal. `input()` on a closed/piped stdin either blocks forever or + # raises EOFError; neither is what a scripted invocation wants. + if not allow_prompt or not sys.stdin.isatty(): return # Prompt for what's still missing. We only prompt for the pieces we - # actually need: server URL, and one of token or username/password. + # actually need: server URL, and one of JWT / token / username+password. if not getattr(args, "server", None): args.server = input("Tableau server URL: ").strip() # Site is optional (empty string is the default site) so we don't prompt. - has_token = getattr(args, "token_name", None) and getattr(args, "token_value", None) - has_user = getattr(args, "username", None) and getattr(args, "password", None) + has_jwt = bool(getattr(args, "jwt", None)) + has_token = bool(getattr(args, "token_name", None) and getattr(args, "token_value", None)) + has_user = bool(getattr(args, "username", None) and getattr(args, "password", None)) - if has_token or has_user: + if has_jwt or has_token or has_user: return - # Nothing configured yet. Ask which auth method to use. - if getattr(args, "token_name", None) or getattr(args, "username", None): - # Partial info supplied -- fill in the matching missing piece. - if getattr(args, "token_name", None) and not getattr(args, "token_value", None): - args.token_value = getpass.getpass(f"Personal access token value for '{args.token_name}': ") - return - if getattr(args, "username", None) and not getattr(args, "password", None): - args.password = getpass.getpass(f"Password for '{args.username}': ") - return + # Partial info supplied -- fill in the matching missing piece. + if getattr(args, "token_name", None) and not getattr(args, "token_value", None): + args.token_value = getpass.getpass(f"Personal access token value for '{args.token_name}': ") + return + if getattr(args, "username", None) and not getattr(args, "password", None): + args.password = getpass.getpass(f"Password for '{args.username}': ") + return # Fully unspecified: default to PAT since that's what the docs recommend. print("No credentials found in args or environment. Sign in with a personal access token.") @@ -173,16 +226,24 @@ def resolve_credentials(args: argparse.Namespace, *, allow_prompt: bool = True) args.token_value = getpass.getpass("Personal access token value: ") -def build_auth(args: argparse.Namespace) -> TSC.TableauAuth | TSC.PersonalAccessTokenAuth: - """Return the appropriate auth object based on what's set on `args`.""" +def build_auth(args: argparse.Namespace) -> TSC.TableauAuth | TSC.PersonalAccessTokenAuth | TSC.JWTAuth: + """Return the appropriate auth object based on what's set on `args`. + + Priority is JWT > PAT > username/password: a script that has a JWT + minted for a specific session should never fall back to a longer-lived + credential if the JWT-adjacent fields were left set by accident. + """ site = getattr(args, "site", None) or "" + if getattr(args, "jwt", None): + return TSC.JWTAuth(args.jwt, site_id=site) if getattr(args, "token_name", None) and getattr(args, "token_value", None): return TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=site) if getattr(args, "username", None) and getattr(args, "password", None): return TSC.TableauAuth(args.username, args.password, site_id=site) raise ValueError( - "No usable credentials found. Provide --token-name/--token-value, " - "--username/--password, or set the corresponding env vars." + "No usable credentials found. Provide --jwt/--jwt-file, " + "--token-name/--token-value, --username/--password, or set the " + "corresponding env vars." ) diff --git a/samples/list_jobs.py b/samples/list_jobs.py index e1835abf4..32ff5e7c8 100644 --- a/samples/list_jobs.py +++ b/samples/list_jobs.py @@ -20,7 +20,7 @@ # # Wait for a specific job to finish. # python samples/list_jobs.py --wait # -# To run the script, you must have installed Python 3.9 or later. +# To run the script, you must have installed Python 3.10 or later. #### import argparse @@ -119,13 +119,16 @@ def _wait_for_job(server, job_id, timeout): """Poll a single job until it finishes, using the built-in helper.""" try: job = server.jobs.wait_for_job(job_id, timeout=timeout) + except JobCancelledException: + # JobCancelledException is a subclass of JobFailedException, so this + # branch must come first or cancelled jobs get reported as failed + # with the wrong exit code. + print(f"Job {job_id} was cancelled.") + raise SystemExit(2) except JobFailedException as exc: # The exception carries the failed JobItem so callers can inspect it. print(f"Job {job_id} failed: notes={exc.job.notes}") raise SystemExit(1) from exc - except JobCancelledException: - print(f"Job {job_id} was cancelled.") - raise SystemExit(2) print(f"Job {job_id} finished. finish_code={job.finish_code} notes={job.notes}") diff --git a/samples/login.py b/samples/login.py index fb339e4e5..13e05294f 100644 --- a/samples/login.py +++ b/samples/login.py @@ -1,13 +1,14 @@ #### # This script demonstrates how to log in to Tableau Server Client. # -# To run the script, you must have installed Python 3.7 or later. +# To run the script, you must have installed Python 3.10 or later. # # Credentials can be supplied on the command line, from environment variables # (TABLEAU_SERVER, TABLEAU_SITE, TABLEAU_TOKEN_NAME, TABLEAU_TOKEN_VALUE, -# TABLEAU_USERNAME, TABLEAU_PASSWORD), from a `.env` file in the current -# working directory, or interactively via getpass. Prefer env or a .env file -# over CLI args so secrets do not end up in your shell history. +# TABLEAU_USERNAME, TABLEAU_PASSWORD, TABLEAU_JWT, TABLEAU_JWT_FILE), from a +# `.env` file in the current working directory (or samples/, or repo root), +# or interactively via getpass. Prefer env or a .env file over CLI args so +# secrets do not end up in your shell history. #### import argparse @@ -35,10 +36,13 @@ def set_up_and_log_in(): def sample_connect_to_server(args): tableau_auth = build_auth(args) - if isinstance(tableau_auth, TSC.PersonalAccessTokenAuth): - print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nToken name: {args.token_name}") + if isinstance(tableau_auth, TSC.JWTAuth): + identifier = "JWT (Connected App)" + elif isinstance(tableau_auth, TSC.PersonalAccessTokenAuth): + identifier = f"Token name: {args.token_name}" else: - print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nUsername: {args.username}") + identifier = f"Username: {args.username}" + print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\n{identifier}") # Only set this to False if you are running against a server you trust AND you know why the cert is broken check_ssl_certificate = True diff --git a/samples/manage_subscriptions.py b/samples/manage_subscriptions.py index 5d8bc70e8..aa9acd6ab 100644 --- a/samples/manage_subscriptions.py +++ b/samples/manage_subscriptions.py @@ -18,10 +18,20 @@ # --schedule-id \ # --subject "Daily sales snapshot" # +# # Create an "On Extract Refresh" subscription (fires when the referenced +# # extract-refresh schedule completes, rather than on the schedule's time +# # trigger). --schedule-id must reference an extract-refresh schedule. +# python samples/manage_subscriptions.py create \ +# --target-type view \ +# --target-id \ +# --schedule-id \ +# --subject "Snapshot when refresh finishes" \ +# --on-extract-refresh +# # # Delete an existing subscription. # python samples/manage_subscriptions.py delete --id # -# To run the script, you must have installed Python 3.9 or later. +# To run the script, you must have installed Python 3.10 or later. #### import argparse @@ -56,19 +66,35 @@ def handle_create(server, args): # The REST API expects lowercase content types ("workbook" or "view"). target = TSC.Target(args.target_id, args.target_type.lower()) - new_sub = TSC.SubscriptionItem( - subject=args.subject, - schedule_id=args.schedule_id, - user_id=user_id, - target=target, - ) + + if args.on_extract_refresh: + # Extract-refresh-triggered: the subscription fires when the referenced + # extract-refresh schedule finishes running the refresh. On Tableau + # Cloud this shows up as schedule type "On Extract Refresh" in the UI. + # `SubscriptionItem.on_extract_refresh` wires up schedule_id and the + # refreshExtractTriggered flag together so the server accepts the + # payload; --schedule-id must reference an extract-refresh schedule. + new_sub = TSC.SubscriptionItem.on_extract_refresh( + subject=args.subject, + extract_refresh_schedule_id=args.schedule_id, + user_id=user_id, + target=target, + ) + else: + new_sub = TSC.SubscriptionItem( + subject=args.subject, + schedule_id=args.schedule_id, + user_id=user_id, + target=target, + ) if args.message: new_sub.message = args.message new_sub.attach_image = args.attach_image new_sub.attach_pdf = args.attach_pdf created = server.subscriptions.create(new_sub) - print(f"Created subscription {created.id} for user {created.user_id} against {created.target}") + trigger = "on-extract-refresh" if args.on_extract_refresh else "on-schedule" + print(f"Created {trigger} subscription {created.id} " f"for user {created.user_id} against {created.target}") def handle_delete(server, args): @@ -98,8 +124,32 @@ def main(): "--user-id", help="User to subscribe. Defaults to the signed-in user.", ) - create_p.add_argument("--attach-image", action="store_true", default=True, help="Attach a PNG snapshot (default).") - create_p.add_argument("--attach-pdf", action="store_true", default=False, help="Also attach a PDF snapshot.") + # BooleanOptionalAction (Python 3.9+) gives us --attach-image / --no-attach-image + # so users can opt out of the default PNG snapshot. Same for the PDF pair for + # symmetry, even though its default is False. + create_p.add_argument( + "--attach-image", + action=argparse.BooleanOptionalAction, + default=True, + help="Attach a PNG snapshot (default: on; pass --no-attach-image to disable).", + ) + create_p.add_argument( + "--attach-pdf", + action=argparse.BooleanOptionalAction, + default=False, + help="Also attach a PDF snapshot (default: off).", + ) + create_p.add_argument( + "--on-extract-refresh", + action="store_true", + default=False, + help=( + "Fire this subscription when the referenced extract-refresh schedule " + "completes, rather than on the schedule's time trigger. --schedule-id " + "must reference an extract-refresh schedule (see create_extract_refresh_" + "subscription.py for the fully worked example)." + ), + ) create_p.set_defaults(func=handle_create) delete_p = subcommands.add_parser("delete", help="Delete a subscription by ID.") diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index 39cc4ba93..a3a952f99 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -4,7 +4,7 @@ # a workbook that matches a given name, download the workbook, # and then publish it to the destination site. # -# To run the script, you must have installed Python 3.7 or later. +# To run the script, you must have installed Python 3.10 or later. #### import argparse diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index 772c436f1..b2648e12c 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -15,7 +15,7 @@ # more information on personal access tokens, refer to the documentations: # (https://help.tableau.com/current/server/en-us/security_personal_access_tokens.htm) # -# To run the script, you must have installed Python 3.7 or later. +# To run the script, you must have installed Python 3.10 or later. #### import argparse diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 7d3bc6b51..bc4882fdf 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -11,7 +11,7 @@ # For more information, refer to the documentations on 'Publish Workbook' # (https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm) # -# To run the script, you must have installed Python 3.7 or later. +# To run the script, you must have installed Python 3.10 or later. #### import argparse @@ -30,7 +30,9 @@ def main(): add_common_arguments(parser) # Options specific to this sample group = parser.add_mutually_exclusive_group(required=False) - group.add_argument("--thumbnails-user-id", "-u", help="User ID to use for thumbnails") + # `-u` is already taken by --username in add_common_arguments; use `-U` here + # so argparse does not raise a conflicting-option-string error at import. + group.add_argument("--thumbnails-user-id", "-U", help="User ID to use for thumbnails") group.add_argument("--thumbnails-group-id", "-g", help="Group ID to use for thumbnails") parser.add_argument("--workbook-name", "-n", help="Name with which to publish the workbook") diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index 16effb004..5435ec1c7 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -2,7 +2,7 @@ # This script demonstrates how to use the Tableau Server Client # to query extract refresh tasks and run them as needed. # -# To run the script, you must have installed Python 3.7 or later. +# To run the script, you must have installed Python 3.10 or later. #### import argparse diff --git a/samples/update_workbook_data_freshness_policy.py b/samples/update_workbook_data_freshness_policy.py index 9eee2fe75..fc3845e6c 100644 --- a/samples/update_workbook_data_freshness_policy.py +++ b/samples/update_workbook_data_freshness_policy.py @@ -2,7 +2,7 @@ # This script demonstrates how to update workbook data freshness policy using the Tableau # Server Client. # -# To run the script, you must have installed Python 3.7 or later. +# To run the script, you must have installed Python 3.10 or later. #### From 98f544d9e8be7388e7db1d92f0b4f900fc7d9bea Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 20 Aug 2026 18:24:29 -0700 Subject: [PATCH 15/15] samples: address remaining fresh-eyes review followups (#1843) Follow-up round of fixes on top of the fresh-eyes review pass. Each change maps to a specific finding from that review. Migrate stragglers to _shared (M4). Eight samples still had their own inline argparse and inline PersonalAccessTokenAuth construction: explore_{datasource,favorites,webhooks,workbook}.py, extracts.py, move_workbook_sites.py, refresh_tasks.py, and update_workbook_data_freshness_policy.py. All now call _shared.add_common_arguments and _shared.build_auth so the tabcmd-aligned short-flag convention (-s -t -u -p -l) applies uniformly and any future credential-handling fix lives in one place. Skip getting_started/3_hello_universe.py: intentionally a hardcoded starter with no argparse, aimed at teaching new users to edit the source directly. Different pedagogy from the CLI samples. Fix explore_favorites empty-site handling (L8). The favorite-datasource add and delete calls used to run unconditionally with my_datasource initialized to None, so on an empty site the sample failed partway. Both calls are now guarded (add inside the existing `if all_datasource_items:` block, delete under a new `if my_datasource is not None:` check). Drop verify=False TLS bypass (L11). Removed http_options={"verify": False} from publish_workbook.py and the equivalent server.add_http_options({"verify": False}) pattern from extracts.py and update_workbook_data_freshness_policy.py. A sample teaching users to bypass TLS validation is the wrong first impression; TSC defaults to verify=True, which is what a paved-path deployment expects. Users on self-signed dev servers can still set the option at their own call site. Delete dead _shared.sign_in() helper (L9). It was not called by any migrated sample: they all use resolve_credentials + build_auth + `with server.auth.sign_in(auth):` for the auto-signout context manager. The helper did not compose with `with` because it returned a Server object rather than a context manager. Co-Authored-By: Claude Opus 4.7 (1M context) --- samples/_shared.py | 15 ------- samples/explore_datasource.py | 23 +++------- samples/explore_favorites.py | 44 +++++++------------ samples/explore_webhooks.py | 24 +++------- samples/explore_workbook.py | 23 +++------- samples/extracts.py | 28 +++--------- samples/move_workbook_sites.py | 22 +++------- samples/publish_workbook.py | 2 +- samples/refresh_tasks.py | 23 +++------- .../update_workbook_data_freshness_policy.py | 28 +++--------- 10 files changed, 62 insertions(+), 170 deletions(-) diff --git a/samples/_shared.py b/samples/_shared.py index 7f13c90c0..686e852c1 100644 --- a/samples/_shared.py +++ b/samples/_shared.py @@ -245,18 +245,3 @@ def build_auth(args: argparse.Namespace) -> TSC.TableauAuth | TSC.PersonalAccess "--token-name/--token-value, --username/--password, or set the " "corresponding env vars." ) - - -def sign_in(args: argparse.Namespace, *, use_server_version: bool = True) -> TSC.Server: - """Convenience helper: resolve credentials, build the server, and sign in. - - The caller is responsible for calling `server.auth.sign_out()` or using - the `with server.auth.sign_in(...)` context manager pattern themselves - when they need finer control. This helper is intended for the small - samples that just want a signed-in server object to poke at. - """ - resolve_credentials(args) - auth = build_auth(args) - server = TSC.Server(args.server, use_server_version=use_server_version) - server.auth.sign_in(auth) - return server diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index 9938aaf09..88ac5ec31 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -14,33 +14,22 @@ import tableauserverclient as TSC +from _shared import add_common_arguments, build_auth, resolve_credentials + 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", 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)", - ) + add_common_arguments(parser) # Options specific to this sample parser.add_argument("--publish", metavar="FILEPATH", help="path to datasource to publish") parser.add_argument("--download", metavar="FILEPATH", help="path to save downloaded datasource") 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) + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) - # SIGN IN - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + tableau_auth = build_auth(args) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): # Query projects for use when demonstrating publishing and updating. diff --git a/samples/explore_favorites.py b/samples/explore_favorites.py index 8358f8d6e..2ad174f7c 100644 --- a/samples/explore_favorites.py +++ b/samples/explore_favorites.py @@ -5,30 +5,19 @@ import tableauserverclient as TSC from tableauserverclient.models import Resource +from _shared import add_common_arguments, build_auth, resolve_credentials + def main(): parser = argparse.ArgumentParser(description="Explore favoriting functions supported by the Server API.") - # 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)", - ) + add_common_arguments(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) + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) - # SIGN IN - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + tableau_auth = build_auth(args) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): print(server) @@ -63,12 +52,12 @@ def main(): all_datasource_items = list(TSC.Pager(server.datasources)) if all_datasource_items: my_datasource = all_datasource_items[0] - server.favorites.add_favorite_datasource(user, my_datasource) - print( - "Datasource added to favorites. Datasource Name: {}, Datasource ID: {}".format( - my_datasource.name, my_datasource.id + server.favorites.add_favorite_datasource(user, my_datasource) + print( + "Datasource added to favorites. Datasource Name: {}, Datasource ID: {}".format( + my_datasource.name, my_datasource.id + ) ) - ) server.favorites.delete_favorite_workbook(user, my_workbook) print(f"Workbook deleted from favorites. Workbook Name: {my_workbook.name}, Workbook ID: {my_workbook.id}") @@ -76,9 +65,10 @@ def main(): server.favorites.delete_favorite_view(user, my_view) print(f"View deleted from favorites. View Name: {my_view.name}, View ID: {my_view.id}") - server.favorites.delete_favorite_datasource(user, my_datasource) - print( - "Datasource deleted from favorites. Datasource Name: {}, Datasource ID: {}".format( - my_datasource.name, my_datasource.id + if my_datasource is not None: + server.favorites.delete_favorite_datasource(user, my_datasource) + print( + "Datasource deleted from favorites. Datasource Name: {}, Datasource ID: {}".format( + my_datasource.name, my_datasource.id + ) ) - ) diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index 129cc6bbf..64a73a430 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -11,37 +11,25 @@ import argparse import logging -import os.path import tableauserverclient as TSC +from _shared import add_common_arguments, build_auth, resolve_credentials + 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", 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)", - ) + add_common_arguments(parser) # Options specific to this sample parser.add_argument("--create", help="create a webhook") parser.add_argument("--delete", help="delete a webhook", action="store_true") 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) + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) - # SIGN IN - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + tableau_auth = build_auth(args) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): # Create webhook if create flag is set (-create, -c) diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index 10af0902d..033dbe594 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -15,21 +15,12 @@ import tableauserverclient as TSC +from _shared import add_common_arguments, build_auth, resolve_credentials + 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", 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)", - ) + add_common_arguments(parser) # Options specific to this sample parser.add_argument("--publish", metavar="FILEPATH", help="path to workbook to publish") parser.add_argument("--download", metavar="FILEPATH", help="path to save downloaded workbook") @@ -42,12 +33,10 @@ def main(): 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) + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) - # SIGN IN - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + tableau_auth = build_auth(args) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): # Publish workbook if publish flag is set (-publish, -p) diff --git a/samples/extracts.py b/samples/extracts.py index 1518adb01..88ef0e382 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -5,25 +5,15 @@ import argparse import logging -import os.path import tableauserverclient as TSC +from _shared import add_common_arguments, build_auth, resolve_credentials + 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", help="server address") - parser.add_argument("--site", help="site name") - 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", - choices=["debug", "info", "error"], - default="error", - help="desired logging level (set to error by default)", - ) + add_common_arguments(parser) # Options specific to this sample parser.add_argument("--create", action="store_true") parser.add_argument("--delete", action="store_true") @@ -32,15 +22,11 @@ def main(): parser.add_argument("--datasource", required=False) 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) + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) - # SIGN IN - 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() + tableau_auth = build_auth(args) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): wb = None ds = None diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index a3a952f99..e4740d8c7 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -14,6 +14,8 @@ import tableauserverclient as TSC +from _shared import add_common_arguments, build_auth, resolve_credentials + def main(): parser = argparse.ArgumentParser( @@ -21,30 +23,18 @@ def main(): "default project of the default site to" "the default project of another site." ) - # 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)", - ) + add_common_arguments(parser) # Options specific to this sample 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() - # Set logging level based on user input, or error by default - logging_level = getattr(logging, args.logging_level.upper()) - logging.basicConfig(level=logging_level) + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) # Step 1: Sign in to both sites on server - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + tableau_auth = build_auth(args) source_server = TSC.Server(args.server) dest_server = TSC.Server(args.server) diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index bc4882fdf..338b4aa28 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -52,7 +52,7 @@ def main(): # Step 1: Sign in to server. tableau_auth = build_auth(args) - server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False}) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): # Step2: Retrieve the project id, if a project name was passed if args.project is not None: diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index 5435ec1c7..15d97bab6 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -10,6 +10,8 @@ import tableauserverclient as TSC +from _shared import add_common_arguments, build_auth, resolve_credentials + def handle_run(server, args): task = server.tasks.get_by_id(args.id) @@ -29,18 +31,7 @@ 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", 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)", - ) + add_common_arguments(parser) # Options specific to this sample subcommands = parser.add_subparsers() @@ -57,12 +48,10 @@ def main(): 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) + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) - # SIGN IN - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + tableau_auth = build_auth(args) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): args.func(server, args) diff --git a/samples/update_workbook_data_freshness_policy.py b/samples/update_workbook_data_freshness_policy.py index fc3845e6c..ffce18b1b 100644 --- a/samples/update_workbook_data_freshness_policy.py +++ b/samples/update_workbook_data_freshness_policy.py @@ -12,36 +12,22 @@ import tableauserverclient as TSC from tableauserverclient import IntervalItem +from _shared import add_common_arguments, build_auth, resolve_credentials + def main(): parser = argparse.ArgumentParser(description="Creates sample schedules for each type of frequency.") - # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", help="server address") - parser.add_argument("--site", "-S", help="site name") - parser.add_argument("--token-name", "-p", help="name of the personal access token " "used to sign into the server") - parser.add_argument( - "--token-value", "-v", help="value of the personal access token " "used to sign into the server" - ) - parser.add_argument( - "--logging-level", - "-l", - choices=["debug", "info", "error"], - default="error", - help="desired logging level (set to error by default)", - ) + add_common_arguments(parser) # 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) + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) - 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() + tableau_auth = build_auth(args) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): # Get workbooks. `.get()` only returns the first page; iterate with # TSC.Pager to see every workbook on the site.