Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
0fe5e96
samples: add shared credential resolver that avoids the command line
jacalata Jul 30, 2026
97ca972
samples: fix mispagination in samples that treated a single page as all
jacalata Jul 30, 2026
6b369de
samples: add list_jobs and manage_subscriptions for coverage gaps
jacalata Jul 30, 2026
54e51f5
samples: align sign-in short flags with tabcmd
jacalata Aug 8, 2026
5b5c90a
feat: expose refreshExtractTriggered on SubscriptionItem (#1658)
jacalata Aug 16, 2026
4207f7f
Address fresh-eyes review on refreshExtractTriggered subscriptions
jacalata Aug 18, 2026
04e4235
samples: add shared credential resolver that avoids the command line
jacalata Jul 30, 2026
aa1d6d9
samples: fix mispagination in samples that treated a single page as all
jacalata Jul 30, 2026
92b1d36
samples: add list_jobs and manage_subscriptions for coverage gaps
jacalata Jul 30, 2026
e3b9d85
samples: align sign-in short flags with tabcmd
jacalata Aug 8, 2026
e9a11fa
samples: fix argparse blocker + 7 bugs, add JWT + on-extract-refresh
jacalata Aug 18, 2026
98f544d
samples: address remaining fresh-eyes review followups (#1843)
jacalata Aug 21, 2026
b3a6c31
Merge origin/jac/samples-improvements to reconcile pre-rebase state
jacalata Aug 21, 2026
bdeecad
Merge remote-tracking branch 'origin/development' into jac/samples-im…
jacalata Sep 10, 2026
69b75a0
Defer subscription refreshExtractTriggered to #1861
jacalata Sep 17, 2026
96aab10
Drop create_extract_refresh_subscription sample, defer to #1861
jacalata Sep 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 247 additions & 0 deletions samples/_shared.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
####
# 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. 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

import argparse
import getpass
import os
import sys
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"),
"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.

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)")
parser.add_argument(
"--token-name",
help="name of the personal access token used to sign into the server " "(env: TABLEAU_TOKEN_NAME)",
)
parser.add_argument(
"--token-value",
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 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 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",
"-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 _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 (only when allow_prompt is true AND stdin is a TTY).

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:
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():
current = getattr(args, field, None)
if current:
continue
env_val = _first_env(env_names)
if env_val:
setattr(args, field, env_val)

# 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
Comment on lines +194 to +198

# Prompt for what's still missing. We only prompt for the pieces we
# 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_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_jwt or has_token or has_user:
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.")
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 | 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 --jwt/--jwt-file, "
"--token-name/--token-value, --username/--password, or set the "
"corresponding env vars."
)
37 changes: 15 additions & 22 deletions samples/explore_datasource.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,38 +14,28 @@

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
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:
Expand All @@ -59,9 +49,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:
Expand Down
51 changes: 21 additions & 30 deletions samples/explore_favorites.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -43,8 +32,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])
Expand All @@ -59,25 +49,26 @@ 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)
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}")

server.favorites.delete_favorite_view(user, my_view)
print(f"View deleted from favorites. View Name: {my_view.name}, View ID: {my_view.id}")
Comment on lines 62 to 66

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
)
)
)
Loading
Loading