Skip to content

Commit b7134a1

Browse files
jacalataclaude
andauthored
samples: credential helper, pagination fixes, and new jobs/subscriptions samples (#1843)
* 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 #1551 item 1. * samples: fix mispagination in samples that treated a single page as all Several samples called `server.<endpoint>.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.<endpoint>)` 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 #1551 item 2 (and #1531). * 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 #1551 item 3. * 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 <password>` silently sent the password as a token name. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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. * 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 #1551 item 1. * samples: fix mispagination in samples that treated a single page as all Several samples called `server.<endpoint>.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.<endpoint>)` 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 #1551 item 2 (and #1531). * 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 #1551 item 3. * 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 <password>` silently sent the password as a token name. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 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> * 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) <noreply@anthropic.com> * Defer subscription refreshExtractTriggered to #1861 Restore subscription_item, subscriptions_endpoint, request_factory, and test_subscription to origin/development state, and drop the matching "On Extract Refresh subscriptions" bullet from CHANGELOG's Unreleased section. That work is being landed via #1861 so it does not need to ride along in this samples-focused PR. Leaves this PR as a pure samples/CHANGELOG-free contribution: shared credential resolver, pagination fixes, list_jobs, manage_subscriptions, and the small samples cleanups already staged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Drop create_extract_refresh_subscription sample, defer to #1861 samples/create_extract_refresh_subscription.py depends on SubscriptionItem.on_extract_refresh(), which is added by #1861 and was already removed from this PR's diff along with the rest of the refreshExtractTriggered work. #1861's branch already carries the same sample byte-identical. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address Copilot + fresh-eyes review on samples Copilot round-2 (2026-09-17) findings: - _shared.py: build_auth now validates args.server so non-TTY callers hit a clear ValueError instead of TSC.Server(None, ...) downstream. - explore_favorites.py: favorite-delete cleanup moved inside the `with server.auth.sign_in(...)` block; each delete guarded by `if my_workbook is not None:` etc. to match the add-side. - update_workbook_data_freshness_policy.py: all_workbooks[2] -> [0] with a follow-up comment; argparse description corrected. Fresh-eyes findings this pass caught: - manage_subscriptions.py: drop the --on-extract-refresh path entirely (docstring, code branch, argparse flag). That relies on SubscriptionItem.on_extract_refresh which lands with #1861 and is not present on this branch after the earlier subscription revert. - extracts.py: `all_workbooks[3]` -> `[0]`; guard the create/delete branches against `wb is None` so `--datasource ... --create` no longer AttributeErrors on `wb.name`; --workbook/--datasource made mutually exclusive to match how the sample is meant to be used. - publish_datasource.py: raise a clear "no project named X" error when the project filter matches zero; fix a swapped-argument print so the datasource id no longer prefixes the "Datasource published" message with the timestamp reading as the id. - refresh_tasks.py: subparsers marked required=True so running the sample with no subcommand prints usage instead of AttributeError. Not fixed in this PR (pre-existing, flagged for follow-up): - explore_workbook.py:120-149 has three latent bugs (missing `=` on `changed`, `c` referenced outside its loop, `--delete` not in this script's argparse). This PR only adds the _shared import; the bugs pre-date it and belong in a separate cleanup PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address Copilot rounds 3/4 remaining findings Round 3 nits: - publish_workbook.py: `-U` comment now says the conflict would happen when the parser is built at run time inside main(), not "at import". - update_workbook_data_freshness_policy.py: `first_page` was assigned but unused; renamed to `_`. Round 4 (after last push): - _shared.py: added the two missing partial-credential branches so a user who supplies TABLEAU_TOKEN_VALUE without TABLEAU_TOKEN_NAME is prompted for the (non-secret) name, and one who supplies a password without a username is prompted for the username. Previously both fell through to the "fully unspecified" PAT prompt. - _shared.py: `--jwt` help text now describes the JWT > PAT > username/password precedence build_auth actually implements, rather than claiming a mutual exclusion that argparse doesn't enforce. - list_jobs.py: `--hours` now passes the tz-aware datetime directly to QuerySet.filter(created_at__gte=...) rather than `.isoformat()`. TSC serializes it as UTC with a trailing Z; the raw isoformat string could produce `+00:00` offsets that older Tableau Server versions reject. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix publish_workbook empty-projects guard + move_workbook_sites help text publish_workbook.py: mirror the empty-projects guard that landed in publish_datasource.py earlier this PR. A --project filter that matches zero results would have slipped past `if len(projects) > 1` and hit `projects[0].id` with an IndexError; now raises a clear ValueError. move_workbook_sites.py: argparse description used implicit string concatenation with missing spaces at the boundaries, so --help printed "...from thedefault project of the default site tothe default project of another site." Reflowed as a parenthesized single-string so the sentence reads correctly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Update publish_datasource header comment for build_auth Comment claimed the sample "uses personal access tokens" for sign-in, but the file now delegates to build_auth() which supports JWT, PAT, and username/password. Reword so users see the full auth surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a1a7ed4 commit b7134a1

15 files changed

Lines changed: 750 additions & 325 deletions

samples/_shared.py

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
####
2+
# Shared helpers for the sample scripts in this directory.
3+
#
4+
# The most important thing here is `resolve_credentials`, which lets samples
5+
# accept a Tableau server URL, site, and credentials from three sources:
6+
#
7+
# 1. Command-line arguments (useful for CI, but note that these end up in
8+
# shell history and process listings, so avoid them for real secrets).
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()`.
17+
#
18+
# CLI args take precedence, then environment, then interactive prompt.
19+
# This lets a user set defaults in a `.env` file and override individual
20+
# 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.
26+
####
27+
28+
from __future__ import annotations
29+
30+
import argparse
31+
import getpass
32+
import os
33+
import sys
34+
from pathlib import Path
35+
from typing import Iterable
36+
37+
import tableauserverclient as TSC
38+
39+
# Recognized environment variable names, in the order we look them up.
40+
# Older samples used TABLEAU_SERVER etc; keep those working as aliases.
41+
_ENV_ALIASES: dict[str, tuple[str, ...]] = {
42+
"server": ("TABLEAU_SERVER", "SERVER"),
43+
"site": ("TABLEAU_SITE", "SITE"),
44+
"token_name": ("TABLEAU_TOKEN_NAME", "TOKEN_NAME"),
45+
"token_value": ("TABLEAU_TOKEN_VALUE", "TOKEN_VALUE"),
46+
"username": ("TABLEAU_USERNAME", "USERNAME"),
47+
"password": ("TABLEAU_PASSWORD", "PASSWORD"),
48+
"jwt": ("TABLEAU_JWT", "JWT"),
49+
"jwt_file": ("TABLEAU_JWT_FILE", "JWT_FILE"),
50+
}
51+
52+
53+
def add_common_arguments(parser: argparse.ArgumentParser) -> None:
54+
"""Add the sign-in and logging arguments used by every sample.
55+
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.
63+
"""
64+
parser.add_argument("--server", "-s", help="server address (env: TABLEAU_SERVER)")
65+
parser.add_argument("--site", "-t", help="site content URL (env: TABLEAU_SITE)")
66+
parser.add_argument(
67+
"--token-name",
68+
help="name of the personal access token used to sign into the server " "(env: TABLEAU_TOKEN_NAME)",
69+
)
70+
parser.add_argument(
71+
"--token-value",
72+
help="value of the personal access token used to sign into the server "
73+
"(env: TABLEAU_TOKEN_VALUE). Prefer the env var or interactive prompt over the "
74+
"command line so the secret does not land in shell history.",
75+
)
76+
parser.add_argument(
77+
"--username",
78+
"-u",
79+
help="username to sign into the server (env: TABLEAU_USERNAME). Only used if "
80+
"no personal access token or JWT is supplied.",
81+
)
82+
parser.add_argument(
83+
"--password",
84+
"-p",
85+
help="password (env: TABLEAU_PASSWORD). Prefer the env var or interactive " "prompt over the command line.",
86+
)
87+
parser.add_argument(
88+
"--jwt",
89+
help="encoded JSON Web Token for Connected-App sign-in (env: TABLEAU_JWT). "
90+
"When multiple auth options are set, JWT wins over PAT and PAT wins over "
91+
"username/password (see build_auth in _shared.py and JWTAuth in the docs).",
92+
)
93+
parser.add_argument(
94+
"--jwt-file",
95+
help="path to a file whose contents are the encoded JWT (env: TABLEAU_JWT_FILE). "
96+
"Useful for pipelines that mint a JWT into a file rather than an env var.",
97+
)
98+
parser.add_argument(
99+
"--env-file",
100+
help="path to a .env-style file with KEY=value lines to load. If omitted, "
101+
".env is looked for in the current directory, the samples/ directory, and "
102+
"the repository root, and the first one found is loaded.",
103+
)
104+
parser.add_argument(
105+
"--logging-level",
106+
"-l",
107+
choices=["debug", "info", "error"],
108+
default="error",
109+
help="desired logging level (set to error by default)",
110+
)
111+
112+
113+
def _load_env_file(path: Path) -> None:
114+
"""Very small `.env` loader: `KEY=value` per line, `#` for comments.
115+
116+
We do not want a runtime dependency on python-dotenv for the samples,
117+
so this parses just the common cases. Existing env vars are not
118+
overwritten -- a value already in `os.environ` wins.
119+
"""
120+
try:
121+
text = path.read_text(encoding="utf-8")
122+
except OSError:
123+
return
124+
for raw_line in text.splitlines():
125+
line = raw_line.strip()
126+
if not line or line.startswith("#") or "=" not in line:
127+
continue
128+
key, _, value = line.partition("=")
129+
key = key.strip()
130+
value = value.strip().strip("'\"")
131+
if key and key not in os.environ:
132+
os.environ[key] = value
133+
134+
135+
def _first_env(names: Iterable[str]) -> str | None:
136+
for name in names:
137+
val = os.environ.get(name)
138+
if val:
139+
return val
140+
return None
141+
142+
143+
def _candidate_env_paths() -> list[Path]:
144+
"""Locations we check for a .env file, in priority order.
145+
146+
cwd first (so the invoker can override), then the directory that holds
147+
this shared module (samples/), then the repository root one level up.
148+
"""
149+
module_dir = Path(__file__).resolve().parent
150+
return [
151+
Path.cwd() / ".env",
152+
module_dir / ".env",
153+
module_dir.parent / ".env",
154+
]
155+
156+
157+
def resolve_credentials(args: argparse.Namespace, *, allow_prompt: bool = True) -> None:
158+
"""Fill in server/site/credential values on `args` from env or prompt.
159+
160+
Precedence for each field: existing value on `args` > environment variable
161+
> interactive prompt (only when allow_prompt is true AND stdin is a TTY).
162+
163+
Pass `allow_prompt=False`, or run with stdin redirected (CI, piped input),
164+
to skip the prompts entirely; the caller should then verify the fields it
165+
needs are set, or let `build_auth` raise a clear ValueError.
166+
"""
167+
# Load `.env` file if one is requested or available.
168+
env_file = getattr(args, "env_file", None)
169+
if env_file:
170+
_load_env_file(Path(env_file))
171+
else:
172+
for candidate in _candidate_env_paths():
173+
if candidate.is_file():
174+
_load_env_file(candidate)
175+
break
176+
177+
# For each field, prefer the CLI arg, then env, then prompt.
178+
for field, env_names in _ENV_ALIASES.items():
179+
current = getattr(args, field, None)
180+
if current:
181+
continue
182+
env_val = _first_env(env_names)
183+
if env_val:
184+
setattr(args, field, env_val)
185+
186+
# If a JWT file was provided, read its contents into args.jwt (unless the
187+
# caller also passed --jwt directly, in which case the direct value wins).
188+
jwt_file = getattr(args, "jwt_file", None)
189+
if jwt_file and not getattr(args, "jwt", None):
190+
try:
191+
args.jwt = Path(jwt_file).read_text(encoding="utf-8").strip()
192+
except OSError as exc:
193+
raise SystemExit(f"Could not read --jwt-file {jwt_file!r}: {exc}") from exc
194+
195+
# Skip prompting entirely if the caller opted out or stdin is not a
196+
# terminal. `input()` on a closed/piped stdin either blocks forever or
197+
# raises EOFError; neither is what a scripted invocation wants.
198+
if not allow_prompt or not sys.stdin.isatty():
199+
return
200+
201+
# Prompt for what's still missing. We only prompt for the pieces we
202+
# actually need: server URL, and one of JWT / token / username+password.
203+
if not getattr(args, "server", None):
204+
args.server = input("Tableau server URL: ").strip()
205+
206+
# Site is optional (empty string is the default site) so we don't prompt.
207+
208+
has_jwt = bool(getattr(args, "jwt", None))
209+
has_token = bool(getattr(args, "token_name", None) and getattr(args, "token_value", None))
210+
has_user = bool(getattr(args, "username", None) and getattr(args, "password", None))
211+
212+
if has_jwt or has_token or has_user:
213+
return
214+
215+
# Partial info supplied -- fill in the matching missing piece. Handle
216+
# both directions of each pair so a user who set only the secret half
217+
# (e.g. TABLEAU_TOKEN_VALUE without TABLEAU_TOKEN_NAME) is prompted for
218+
# the non-secret half, not asked to re-type the secret they already have.
219+
if getattr(args, "token_name", None) and not getattr(args, "token_value", None):
220+
args.token_value = getpass.getpass(f"Personal access token value for '{args.token_name}': ")
221+
return
222+
if getattr(args, "token_value", None) and not getattr(args, "token_name", None):
223+
args.token_name = input("Personal access token name: ").strip()
224+
return
225+
if getattr(args, "username", None) and not getattr(args, "password", None):
226+
args.password = getpass.getpass(f"Password for '{args.username}': ")
227+
return
228+
if getattr(args, "password", None) and not getattr(args, "username", None):
229+
args.username = input("Username: ").strip()
230+
return
231+
232+
# Fully unspecified: default to PAT since that's what the docs recommend.
233+
print("No credentials found in args or environment. Sign in with a personal access token.")
234+
print("(Set TABLEAU_TOKEN_NAME / TABLEAU_TOKEN_VALUE in your env or a .env file to skip this prompt.)")
235+
args.token_name = input("Personal access token name: ").strip()
236+
args.token_value = getpass.getpass("Personal access token value: ")
237+
238+
239+
def build_auth(args: argparse.Namespace) -> TSC.TableauAuth | TSC.PersonalAccessTokenAuth | TSC.JWTAuth:
240+
"""Return the appropriate auth object based on what's set on `args`.
241+
242+
Priority is JWT > PAT > username/password: a script that has a JWT
243+
minted for a specific session should never fall back to a longer-lived
244+
credential if the JWT-adjacent fields were left set by accident.
245+
246+
Also validates that `--server` is set. `resolve_credentials` skips prompting
247+
in non-interactive contexts (CI, piped stdin), so a missing server URL would
248+
otherwise reach `TSC.Server(None, ...)` and fail with a confusing error;
249+
catching it here gives the caller a clear message.
250+
"""
251+
if not getattr(args, "server", None):
252+
raise ValueError(
253+
"No Tableau server URL. Provide --server, set the TABLEAU_SERVER env "
254+
"var, or run in an interactive terminal to be prompted."
255+
)
256+
site = getattr(args, "site", None) or ""
257+
if getattr(args, "jwt", None):
258+
return TSC.JWTAuth(args.jwt, site_id=site)
259+
if getattr(args, "token_name", None) and getattr(args, "token_value", None):
260+
return TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=site)
261+
if getattr(args, "username", None) and getattr(args, "password", None):
262+
return TSC.TableauAuth(args.username, args.password, site_id=site)
263+
raise ValueError(
264+
"No usable credentials found. Provide --jwt/--jwt-file, "
265+
"--token-name/--token-value, --username/--password, or set the "
266+
"corresponding env vars."
267+
)

samples/explore_datasource.py

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,38 +14,28 @@
1414

1515
import tableauserverclient as TSC
1616

17+
from _shared import add_common_arguments, build_auth, resolve_credentials
18+
1719

1820
def main():
1921
parser = argparse.ArgumentParser(description="Explore datasource functions supported by the Server API.")
20-
# Common options; please keep those in sync across all samples
21-
parser.add_argument("--server", "-s", help="server address")
22-
parser.add_argument("--site", "-S", help="site name")
23-
parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
24-
parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
25-
parser.add_argument(
26-
"--logging-level",
27-
"-l",
28-
choices=["debug", "info", "error"],
29-
default="error",
30-
help="desired logging level (set to error by default)",
31-
)
22+
add_common_arguments(parser)
3223
# Options specific to this sample
3324
parser.add_argument("--publish", metavar="FILEPATH", help="path to datasource to publish")
3425
parser.add_argument("--download", metavar="FILEPATH", help="path to save downloaded datasource")
3526

3627
args = parser.parse_args()
3728

38-
# Set logging level based on user input, or error by default
39-
logging_level = getattr(logging, args.logging_level.upper())
40-
logging.basicConfig(level=logging_level)
29+
resolve_credentials(args)
30+
logging.basicConfig(level=getattr(logging, args.logging_level.upper()))
4131

42-
# SIGN IN
43-
tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
32+
tableau_auth = build_auth(args)
4433
server = TSC.Server(args.server, use_server_version=True)
4534
with server.auth.sign_in(tableau_auth):
46-
# Query projects for use when demonstrating publishing and updating
47-
all_projects, pagination_item = server.projects.get()
48-
default_project = next((project for project in all_projects if project.is_default()), None)
35+
# Query projects for use when demonstrating publishing and updating.
36+
# Use TSC.Pager (or `.all()` / `.filter()`) to iterate every page;
37+
# a raw `server.projects.get()` only returns the first page.
38+
default_project = next((project for project in TSC.Pager(server.projects) if project.is_default()), None)
4939

5040
# Publish datasource if publish flag is set (-publish, -p)
5141
if args.publish:
@@ -59,9 +49,12 @@ def main():
5949
else:
6050
print("Publish failed. Could not find the default project.")
6151

62-
# Gets all datasource items
63-
all_datasources, pagination_item = server.datasources.get()
52+
# Gets all datasource items. `.get()` returns only one page; use
53+
# TSC.Pager to iterate every page. The first response also gives us
54+
# the total_available count without paging through everything.
55+
first_page, pagination_item = server.datasources.get()
6456
print(f"\nThere are {pagination_item.total_available} datasources on site: ")
57+
all_datasources = list(TSC.Pager(server.datasources))
6558
print([datasource.name for datasource in all_datasources])
6659

6760
if all_datasources:

0 commit comments

Comments
 (0)