Skip to content

Commit e9a11fa

Browse files
jacalataclaude
andcommitted
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) <noreply@anthropic.com>
1 parent e3b9d85 commit e9a11fa

9 files changed

Lines changed: 181 additions & 61 deletions

samples/_shared.py

Lines changed: 95 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,31 @@
66
#
77
# 1. Command-line arguments (useful for CI, but note that these end up in
88
# shell history and process listings, so avoid them for real secrets).
9-
# 2. Environment variables. If a `.env` file exists next to the sample
10-
# being run, or in the current working directory, we load it first --
11-
# only the standard `KEY=value` lines, no external dependency required.
12-
# 3. Interactive prompts. Missing values are asked for on stdin; secrets
13-
# are read with `getpass.getpass` so they are not echoed.
9+
# 2. Environment variables. We look for a `.env` file in the current
10+
# working directory, in the samples/ directory, and at the repository
11+
# root, in that order, and load whichever we find first -- only the
12+
# standard `KEY=value` lines, no external dependency required.
13+
# 3. Interactive prompts. Missing values are asked for on stdin when
14+
# stdin is a terminal; secrets are read with `getpass.getpass` so they
15+
# are not echoed. In non-interactive contexts (CI, piped input) we skip
16+
# the prompts and let `build_auth` raise instead of hanging on `input()`.
1417
#
1518
# CLI args take precedence, then environment, then interactive prompt.
1619
# This lets a user set defaults in a `.env` file and override individual
1720
# values on the command line.
21+
#
22+
# Sign-in short flags follow the tabcmd convention (-s server, -t site,
23+
# -u username, -p password). --token-name and --token-value do not have
24+
# short flags because tabcmd does not either and re-using a letter here
25+
# would silently accept a token as a password on old command lines.
1826
####
1927

2028
from __future__ import annotations
2129

2230
import argparse
2331
import getpass
2432
import os
33+
import sys
2534
from pathlib import Path
2635
from typing import Iterable
2736

@@ -36,15 +45,21 @@
3645
"token_value": ("TABLEAU_TOKEN_VALUE", "TOKEN_VALUE"),
3746
"username": ("TABLEAU_USERNAME", "USERNAME"),
3847
"password": ("TABLEAU_PASSWORD", "PASSWORD"),
48+
"jwt": ("TABLEAU_JWT", "JWT"),
49+
"jwt_file": ("TABLEAU_JWT_FILE", "JWT_FILE"),
3950
}
4051

4152

4253
def add_common_arguments(parser: argparse.ArgumentParser) -> None:
4354
"""Add the sign-in and logging arguments used by every sample.
4455
45-
Kept in sync with the historical inline definitions so no existing
46-
command line breaks. All arguments are optional -- missing values
47-
are pulled from the environment or prompted for interactively.
56+
Short flags follow the tabcmd convention: -s server, -t site,
57+
-u username, -p password, -l logging-level. --token-name /
58+
--token-value and --jwt / --jwt-file intentionally have no short
59+
flag; re-using letters here risked silently accepting a token as
60+
a password on scripts that pre-date the shared helper. All args
61+
are optional; missing values are pulled from the environment or
62+
prompted for interactively.
4863
"""
4964
parser.add_argument("--server", "-s", help="server address (env: TABLEAU_SERVER)")
5065
parser.add_argument("--site", "-t", help="site content URL (env: TABLEAU_SITE)")
@@ -62,17 +77,28 @@ def add_common_arguments(parser: argparse.ArgumentParser) -> None:
6277
"--username",
6378
"-u",
6479
help="username to sign into the server (env: TABLEAU_USERNAME). Only used if "
65-
"no personal access token is supplied.",
80+
"no personal access token or JWT is supplied.",
6681
)
6782
parser.add_argument(
6883
"--password",
6984
"-p",
7085
help="password (env: TABLEAU_PASSWORD). Prefer the env var or interactive " "prompt over the command line.",
7186
)
87+
parser.add_argument(
88+
"--jwt",
89+
help="encoded JSON Web Token for Connected-App sign-in (env: TABLEAU_JWT). "
90+
"Mutually exclusive with token/username auth; see JWTAuth in the docs.",
91+
)
92+
parser.add_argument(
93+
"--jwt-file",
94+
help="path to a file whose contents are the encoded JWT (env: TABLEAU_JWT_FILE). "
95+
"Useful for pipelines that mint a JWT into a file rather than an env var.",
96+
)
7297
parser.add_argument(
7398
"--env-file",
7499
help="path to a .env-style file with KEY=value lines to load. If omitted, "
75-
".env in the current directory is loaded automatically when present.",
100+
".env is looked for in the current directory, the samples/ directory, and "
101+
"the repository root, and the first one found is loaded.",
76102
)
77103
parser.add_argument(
78104
"--logging-level",
@@ -113,23 +139,39 @@ def _first_env(names: Iterable[str]) -> str | None:
113139
return None
114140

115141

142+
def _candidate_env_paths() -> list[Path]:
143+
"""Locations we check for a .env file, in priority order.
144+
145+
cwd first (so the invoker can override), then the directory that holds
146+
this shared module (samples/), then the repository root one level up.
147+
"""
148+
module_dir = Path(__file__).resolve().parent
149+
return [
150+
Path.cwd() / ".env",
151+
module_dir / ".env",
152+
module_dir.parent / ".env",
153+
]
154+
155+
116156
def resolve_credentials(args: argparse.Namespace, *, allow_prompt: bool = True) -> None:
117157
"""Fill in server/site/credential values on `args` from env or prompt.
118158
119159
Precedence for each field: existing value on `args` > environment variable
120-
> interactive prompt (if allow_prompt and stdin is a terminal).
160+
> interactive prompt (only when allow_prompt is true AND stdin is a TTY).
121161
122-
Pass `allow_prompt=False` in CI environments where blocking on input would
123-
hang the job; the caller should then verify the fields it needs are set.
162+
Pass `allow_prompt=False`, or run with stdin redirected (CI, piped input),
163+
to skip the prompts entirely; the caller should then verify the fields it
164+
needs are set, or let `build_auth` raise a clear ValueError.
124165
"""
125166
# Load `.env` file if one is requested or available.
126167
env_file = getattr(args, "env_file", None)
127168
if env_file:
128169
_load_env_file(Path(env_file))
129170
else:
130-
default_env = Path.cwd() / ".env"
131-
if default_env.is_file():
132-
_load_env_file(default_env)
171+
for candidate in _candidate_env_paths():
172+
if candidate.is_file():
173+
_load_env_file(candidate)
174+
break
133175

134176
# For each field, prefer the CLI arg, then env, then prompt.
135177
for field, env_names in _ENV_ALIASES.items():
@@ -140,31 +182,42 @@ def resolve_credentials(args: argparse.Namespace, *, allow_prompt: bool = True)
140182
if env_val:
141183
setattr(args, field, env_val)
142184

143-
if not allow_prompt:
185+
# If a JWT file was provided, read its contents into args.jwt (unless the
186+
# caller also passed --jwt directly, in which case the direct value wins).
187+
jwt_file = getattr(args, "jwt_file", None)
188+
if jwt_file and not getattr(args, "jwt", None):
189+
try:
190+
args.jwt = Path(jwt_file).read_text(encoding="utf-8").strip()
191+
except OSError as exc:
192+
raise SystemExit(f"Could not read --jwt-file {jwt_file!r}: {exc}") from exc
193+
194+
# Skip prompting entirely if the caller opted out or stdin is not a
195+
# terminal. `input()` on a closed/piped stdin either blocks forever or
196+
# raises EOFError; neither is what a scripted invocation wants.
197+
if not allow_prompt or not sys.stdin.isatty():
144198
return
145199

146200
# Prompt for what's still missing. We only prompt for the pieces we
147-
# actually need: server URL, and one of token or username/password.
201+
# actually need: server URL, and one of JWT / token / username+password.
148202
if not getattr(args, "server", None):
149203
args.server = input("Tableau server URL: ").strip()
150204

151205
# Site is optional (empty string is the default site) so we don't prompt.
152206

153-
has_token = getattr(args, "token_name", None) and getattr(args, "token_value", None)
154-
has_user = getattr(args, "username", None) and getattr(args, "password", None)
207+
has_jwt = bool(getattr(args, "jwt", None))
208+
has_token = bool(getattr(args, "token_name", None) and getattr(args, "token_value", None))
209+
has_user = bool(getattr(args, "username", None) and getattr(args, "password", None))
155210

156-
if has_token or has_user:
211+
if has_jwt or has_token or has_user:
157212
return
158213

159-
# Nothing configured yet. Ask which auth method to use.
160-
if getattr(args, "token_name", None) or getattr(args, "username", None):
161-
# Partial info supplied -- fill in the matching missing piece.
162-
if getattr(args, "token_name", None) and not getattr(args, "token_value", None):
163-
args.token_value = getpass.getpass(f"Personal access token value for '{args.token_name}': ")
164-
return
165-
if getattr(args, "username", None) and not getattr(args, "password", None):
166-
args.password = getpass.getpass(f"Password for '{args.username}': ")
167-
return
214+
# Partial info supplied -- fill in the matching missing piece.
215+
if getattr(args, "token_name", None) and not getattr(args, "token_value", None):
216+
args.token_value = getpass.getpass(f"Personal access token value for '{args.token_name}': ")
217+
return
218+
if getattr(args, "username", None) and not getattr(args, "password", None):
219+
args.password = getpass.getpass(f"Password for '{args.username}': ")
220+
return
168221

169222
# Fully unspecified: default to PAT since that's what the docs recommend.
170223
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)
173226
args.token_value = getpass.getpass("Personal access token value: ")
174227

175228

176-
def build_auth(args: argparse.Namespace) -> TSC.TableauAuth | TSC.PersonalAccessTokenAuth:
177-
"""Return the appropriate auth object based on what's set on `args`."""
229+
def build_auth(args: argparse.Namespace) -> TSC.TableauAuth | TSC.PersonalAccessTokenAuth | TSC.JWTAuth:
230+
"""Return the appropriate auth object based on what's set on `args`.
231+
232+
Priority is JWT > PAT > username/password: a script that has a JWT
233+
minted for a specific session should never fall back to a longer-lived
234+
credential if the JWT-adjacent fields were left set by accident.
235+
"""
178236
site = getattr(args, "site", None) or ""
237+
if getattr(args, "jwt", None):
238+
return TSC.JWTAuth(args.jwt, site_id=site)
179239
if getattr(args, "token_name", None) and getattr(args, "token_value", None):
180240
return TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=site)
181241
if getattr(args, "username", None) and getattr(args, "password", None):
182242
return TSC.TableauAuth(args.username, args.password, site_id=site)
183243
raise ValueError(
184-
"No usable credentials found. Provide --token-name/--token-value, "
185-
"--username/--password, or set the corresponding env vars."
244+
"No usable credentials found. Provide --jwt/--jwt-file, "
245+
"--token-name/--token-value, --username/--password, or set the "
246+
"corresponding env vars."
186247
)
187248

188249

samples/list_jobs.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
# # Wait for a specific job to finish.
2121
# python samples/list_jobs.py --wait <job_id>
2222
#
23-
# To run the script, you must have installed Python 3.9 or later.
23+
# To run the script, you must have installed Python 3.10 or later.
2424
####
2525

2626
import argparse
@@ -119,13 +119,16 @@ def _wait_for_job(server, job_id, timeout):
119119
"""Poll a single job until it finishes, using the built-in helper."""
120120
try:
121121
job = server.jobs.wait_for_job(job_id, timeout=timeout)
122+
except JobCancelledException:
123+
# JobCancelledException is a subclass of JobFailedException, so this
124+
# branch must come first or cancelled jobs get reported as failed
125+
# with the wrong exit code.
126+
print(f"Job {job_id} was cancelled.")
127+
raise SystemExit(2)
122128
except JobFailedException as exc:
123129
# The exception carries the failed JobItem so callers can inspect it.
124130
print(f"Job {job_id} failed: notes={exc.job.notes}")
125131
raise SystemExit(1) from exc
126-
except JobCancelledException:
127-
print(f"Job {job_id} was cancelled.")
128-
raise SystemExit(2)
129132

130133
print(f"Job {job_id} finished. finish_code={job.finish_code} notes={job.notes}")
131134

samples/login.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
####
22
# This script demonstrates how to log in to Tableau Server Client.
33
#
4-
# To run the script, you must have installed Python 3.7 or later.
4+
# To run the script, you must have installed Python 3.10 or later.
55
#
66
# Credentials can be supplied on the command line, from environment variables
77
# (TABLEAU_SERVER, TABLEAU_SITE, TABLEAU_TOKEN_NAME, TABLEAU_TOKEN_VALUE,
8-
# TABLEAU_USERNAME, TABLEAU_PASSWORD), from a `.env` file in the current
9-
# working directory, or interactively via getpass. Prefer env or a .env file
10-
# over CLI args so secrets do not end up in your shell history.
8+
# TABLEAU_USERNAME, TABLEAU_PASSWORD, TABLEAU_JWT, TABLEAU_JWT_FILE), from a
9+
# `.env` file in the current working directory (or samples/, or repo root),
10+
# or interactively via getpass. Prefer env or a .env file over CLI args so
11+
# secrets do not end up in your shell history.
1112
####
1213

1314
import argparse
@@ -35,10 +36,13 @@ def set_up_and_log_in():
3536

3637
def sample_connect_to_server(args):
3738
tableau_auth = build_auth(args)
38-
if isinstance(tableau_auth, TSC.PersonalAccessTokenAuth):
39-
print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nToken name: {args.token_name}")
39+
if isinstance(tableau_auth, TSC.JWTAuth):
40+
identifier = "JWT (Connected App)"
41+
elif isinstance(tableau_auth, TSC.PersonalAccessTokenAuth):
42+
identifier = f"Token name: {args.token_name}"
4043
else:
41-
print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nUsername: {args.username}")
44+
identifier = f"Username: {args.username}"
45+
print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\n{identifier}")
4246

4347
# Only set this to False if you are running against a server you trust AND you know why the cert is broken
4448
check_ssl_certificate = True

samples/manage_subscriptions.py

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,20 @@
1818
# --schedule-id <schedule_id> \
1919
# --subject "Daily sales snapshot"
2020
#
21+
# # Create an "On Extract Refresh" subscription (fires when the referenced
22+
# # extract-refresh schedule completes, rather than on the schedule's time
23+
# # trigger). --schedule-id must reference an extract-refresh schedule.
24+
# python samples/manage_subscriptions.py create \
25+
# --target-type view \
26+
# --target-id <view_id> \
27+
# --schedule-id <extract_refresh_schedule_id> \
28+
# --subject "Snapshot when refresh finishes" \
29+
# --on-extract-refresh
30+
#
2131
# # Delete an existing subscription.
2232
# python samples/manage_subscriptions.py delete --id <subscription_id>
2333
#
24-
# To run the script, you must have installed Python 3.9 or later.
34+
# To run the script, you must have installed Python 3.10 or later.
2535
####
2636

2737
import argparse
@@ -56,19 +66,35 @@ def handle_create(server, args):
5666

5767
# The REST API expects lowercase content types ("workbook" or "view").
5868
target = TSC.Target(args.target_id, args.target_type.lower())
59-
new_sub = TSC.SubscriptionItem(
60-
subject=args.subject,
61-
schedule_id=args.schedule_id,
62-
user_id=user_id,
63-
target=target,
64-
)
69+
70+
if args.on_extract_refresh:
71+
# Extract-refresh-triggered: the subscription fires when the referenced
72+
# extract-refresh schedule finishes running the refresh. On Tableau
73+
# Cloud this shows up as schedule type "On Extract Refresh" in the UI.
74+
# `SubscriptionItem.on_extract_refresh` wires up schedule_id and the
75+
# refreshExtractTriggered flag together so the server accepts the
76+
# payload; --schedule-id must reference an extract-refresh schedule.
77+
new_sub = TSC.SubscriptionItem.on_extract_refresh(
78+
subject=args.subject,
79+
extract_refresh_schedule_id=args.schedule_id,
80+
user_id=user_id,
81+
target=target,
82+
)
83+
else:
84+
new_sub = TSC.SubscriptionItem(
85+
subject=args.subject,
86+
schedule_id=args.schedule_id,
87+
user_id=user_id,
88+
target=target,
89+
)
6590
if args.message:
6691
new_sub.message = args.message
6792
new_sub.attach_image = args.attach_image
6893
new_sub.attach_pdf = args.attach_pdf
6994

7095
created = server.subscriptions.create(new_sub)
71-
print(f"Created subscription {created.id} for user {created.user_id} against {created.target}")
96+
trigger = "on-extract-refresh" if args.on_extract_refresh else "on-schedule"
97+
print(f"Created {trigger} subscription {created.id} " f"for user {created.user_id} against {created.target}")
7298

7399

74100
def handle_delete(server, args):
@@ -98,8 +124,32 @@ def main():
98124
"--user-id",
99125
help="User to subscribe. Defaults to the signed-in user.",
100126
)
101-
create_p.add_argument("--attach-image", action="store_true", default=True, help="Attach a PNG snapshot (default).")
102-
create_p.add_argument("--attach-pdf", action="store_true", default=False, help="Also attach a PDF snapshot.")
127+
# BooleanOptionalAction (Python 3.9+) gives us --attach-image / --no-attach-image
128+
# so users can opt out of the default PNG snapshot. Same for the PDF pair for
129+
# symmetry, even though its default is False.
130+
create_p.add_argument(
131+
"--attach-image",
132+
action=argparse.BooleanOptionalAction,
133+
default=True,
134+
help="Attach a PNG snapshot (default: on; pass --no-attach-image to disable).",
135+
)
136+
create_p.add_argument(
137+
"--attach-pdf",
138+
action=argparse.BooleanOptionalAction,
139+
default=False,
140+
help="Also attach a PDF snapshot (default: off).",
141+
)
142+
create_p.add_argument(
143+
"--on-extract-refresh",
144+
action="store_true",
145+
default=False,
146+
help=(
147+
"Fire this subscription when the referenced extract-refresh schedule "
148+
"completes, rather than on the schedule's time trigger. --schedule-id "
149+
"must reference an extract-refresh schedule (see create_extract_refresh_"
150+
"subscription.py for the fully worked example)."
151+
),
152+
)
103153
create_p.set_defaults(func=handle_create)
104154

105155
delete_p = subcommands.add_parser("delete", help="Delete a subscription by ID.")

samples/move_workbook_sites.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# a workbook that matches a given name, download the workbook,
55
# and then publish it to the destination site.
66
#
7-
# To run the script, you must have installed Python 3.7 or later.
7+
# To run the script, you must have installed Python 3.10 or later.
88
####
99

1010
import argparse

0 commit comments

Comments
 (0)