From 0fe5e961ebd97a4203eee5abcfb506533a2452a1 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 30 Jul 2026 15:29:40 -0700 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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()