From 5d50c15ae20971df5e890f25cd048ec2ed6d2d3f Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Wed, 26 Aug 2026 11:56:46 -0700 Subject: [PATCH 1/2] docs: consolidate auth samples into a single samples/login.py Merge the previously-separate samples/auth_from_env.py into samples/login.py so there is one canonical demo of how to sign in to Tableau Server. The consolidated sample now supports three credential sources composed in precedence order: CLI args, TABLEAU_* env vars, and (with --interactive) a getpass password prompt. Env-var names are TABLEAU_-prefixed to avoid collision with generic shell vars like USERNAME (which Windows sets automatically for the current OS user). Public helpers on samples/login.py: - sample_define_common_options(parser) -- unchanged name; adds --interactive and --api-version. - get_env(key, default=None) -- unchanged name. - resolve_credentials(args) -- new; CLI -> env -> prompt. - build_server_and_auth(args) -- new; returns (Server, Auth) without signing in, replacing load_from_env's return shape. - sample_connect_to_server(args) -- unchanged name; now calls resolve_credentials + build_server_and_auth then signs in. - set_up_and_log_in() -- unchanged main entry. Removes the "Personal Access Token:" getpass fallback in sample_connect_to_server -- nobody wants to type a 40-character random string; a missing PAT half now raises the partial-credentials error. The password getpass prompt is likewise gated on --interactive rather than triggering silently on a missing --password. Env-var rename (breaks anyone relying on the pre-change bare names in login.py::get_env calls, though no other sample called those helpers): SERVER -> TABLEAU_SERVER SITE -> TABLEAU_SITE TOKEN_NAME -> TABLEAU_TOKEN_NAME TOKEN_VALUE -> TABLEAU_TOKEN samples/auth_from_env.py is deleted; its load_from_env() shape is now available as resolve_credentials(args) + build_server_and_auth(args). The README subsection is retained but points at samples/login.py and documents the three-source precedence. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 23 +++++- samples/login.py | 189 ++++++++++++++++++++++++++++++++++------------- 2 files changed, 160 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 5c80f337e..a016c08ed 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,28 @@ To see sample code that works directly with the REST API (in Java, Python, or Po For more information on installing and using TSC, see the documentation: - + +### Authenticating from environment variables + +The [`samples/login.py`](samples/login.py) sample shows three ways to +supply Tableau credentials, in precedence order: + +1. Command-line arguments (`--server`, `--username`, `--password`, + `--token-name`, `--token-value`, `--site`, `--api-version`) +2. `TABLEAU_*` environment variables — one way to keep credentials out + of your source code: + * `TABLEAU_SERVER` (required) — server URL + * `TABLEAU_SITE` (optional) — site content URL; `""` for the default site + * `TABLEAU_TOKEN_NAME` + `TABLEAU_TOKEN` — personal access token (preferred) + * `TABLEAU_USERNAME` + `TABLEAU_PASSWORD` — basic auth (fallback) + * `TABLEAU_API_VERSION` (optional) — pin REST API version; otherwise + the sample negotiates with the server +3. Pass `--interactive` to prompt for a missing password on a terminal + via `getpass` instead of exporting it. PATs are never prompted for. + +Names are `TABLEAU_`-prefixed to avoid collision with generic shell +variables like `USERNAME` (which Windows sets automatically). + To contribute, see our [Developer Guide](https://tableau.github.io/server-client-python/docs/dev-guide). A list of all our contributors to date is in [CONTRIBUTORS.md]. ## License diff --git a/samples/login.py b/samples/login.py index bc99385b3..9f9e76926 100644 --- a/samples/login.py +++ b/samples/login.py @@ -1,55 +1,64 @@ #### -# This script demonstrates how to log in to Tableau Server Client. +# This sample demonstrates three ways to supply Tableau credentials -- +# they compose in this precedence order: # -# To run the script, you must have installed Python 3.7 or later. +# 1. Command-line arguments (highest precedence) +# 2. TABLEAU_* environment variables +# 3. Interactive password prompt via --interactive (password only) +# +# Any credential missing from the CLI is looked up in the environment; +# with --interactive, a missing password is prompted for on the terminal +# via getpass. PATs are never prompted for; nobody wants to type a +# 40-character random string. +# +# Environment variables are TABLEAU_-prefixed to avoid collision with +# generic shell vars like USERNAME (which Windows sets automatically): +# TABLEAU_SERVER (required) Server URL, e.g. https://10ax.online.tableau.com +# TABLEAU_SITE (optional) Site content URL; "" for the default site +# TABLEAU_TOKEN_NAME PAT name (preferred if both PAT vars are set) +# TABLEAU_TOKEN PAT value (preferred if both PAT vars are set) +# TABLEAU_USERNAME username (fallback if both basic vars are set) +# TABLEAU_PASSWORD password (fallback if both basic vars are set) +# TABLEAU_API_VERSION (optional) Pin REST API version; if absent, the +# sample negotiates with the server. +# +# To run this sample, you must have installed Python 3.10 or later. #### import argparse import getpass import logging import os +import sys import tableauserverclient as TSC +logger = logging.getLogger(__name__) -def get_env(key): - if key in os.environ: - return os.environ[key] - return None - - -# 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 -def set_up_and_log_in(): - parser = argparse.ArgumentParser(description="Logs in to the server.") - sample_define_common_options(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" - server = sample_connect_to_server(args) - print(server.server_info.get()) - print(server.server_address, "site:", server.site_id, "user:", server.user_id) +def get_env(key: str, default: str | None = None) -> str | None: + """Return the value of environment variable ``key``, or ``default`` if unset.""" + return os.environ.get(key, default) -def sample_define_common_options(parser): - # Common options; please keep these in sync across all samples by copying or calling this method directly +# If a sample has additional arguments, it should call this method and then add its +# own; otherwise it can just call set_up_and_log_in(). +def sample_define_common_options(parser: argparse.ArgumentParser) -> None: + """Add the standard credential/logging arguments to an argparse parser.""" parser.add_argument("--server", "-s", help="server address") - parser.add_argument("--site", "-t", help="site name") + parser.add_argument("--site", "-t", help="site content URL; '' for the default site") 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("--password", "-p", help="password used to sign into the server") + parser.add_argument("--api-version", help="pin a REST API version; otherwise auto-negotiate with the server") + parser.add_argument( + "--interactive", + action="store_true", + help="prompt for a missing password via getpass (requires a TTY)", + ) parser.add_argument( "--logging-level", "-l", @@ -59,36 +68,114 @@ def sample_define_common_options(parser): ) -def sample_connect_to_server(args): - if args.username: - # Trying to authenticate using username and password. - password = args.password or getpass.getpass("Password: ") +def resolve_credentials(args: argparse.Namespace) -> argparse.Namespace: + """Populate credential fields on ``args`` from TABLEAU_* env vars, and + (with ``--interactive``) an interactive password prompt. + + Precedence per field: CLI arg > TABLEAU_* env var > (password only, + when ``--interactive`` is set, ``--username`` is set, and stdin is a + TTY) getpass prompt. + + Raises ``ValueError`` on a partial credential pair (username without + password, or token-name without token-value), or if ``--interactive`` + was requested but stdin is not a TTY so the prompt would hang. + """ + field_env = { + "server": "TABLEAU_SERVER", + "site": "TABLEAU_SITE", + "token_name": "TABLEAU_TOKEN_NAME", + "token_value": "TABLEAU_TOKEN", + "username": "TABLEAU_USERNAME", + "password": "TABLEAU_PASSWORD", + "api_version": "TABLEAU_API_VERSION", + } + for field, env_var in field_env.items(): + if getattr(args, field, None) is None: + setattr(args, field, get_env(env_var)) + + if args.interactive and args.username and not args.password: + if not sys.stdin.isatty(): + raise ValueError( + "--interactive requires a TTY; set TABLEAU_PASSWORD/--password " "or run from a real terminal" + ) + args.password = getpass.getpass(f"Password for {args.username}: ") + + if bool(args.token_name) ^ bool(args.token_value): + missing = "TABLEAU_TOKEN/--token-value" if args.token_name else "TABLEAU_TOKEN_NAME/--token-name" + raise ValueError(f"Partial PAT credentials: {missing} is not set") + if bool(args.username) ^ bool(args.password): + missing = "TABLEAU_PASSWORD/--password" if args.username else "TABLEAU_USERNAME/--username" + raise ValueError(f"Partial basic credentials: {missing} is not set") + + return args + + +def build_server_and_auth( + args: argparse.Namespace, +) -> tuple[TSC.Server, TSC.TableauAuth | TSC.PersonalAccessTokenAuth]: + """Build the Server and Auth objects from resolved args. Does NOT sign in. + + Callers who want to control the sign-in scope themselves (e.g. wrap it + in ``with server.auth.sign_in(auth): ...``) should use this instead of + :func:`sample_connect_to_server`. + """ + if not args.server: + raise ValueError("Server URL is required: pass --server or set TABLEAU_SERVER") - tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.site) - print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nUsername: {args.username}") + site = args.site or "" + if args.token_name and args.token_value: + auth: TSC.TableauAuth | TSC.PersonalAccessTokenAuth = TSC.PersonalAccessTokenAuth( + token_name=args.token_name, personal_access_token=args.token_value, site_id=site + ) + logger.info("Using PAT authentication") + elif args.username and args.password: + auth = TSC.TableauAuth(username=args.username, password=args.password, site_id=site) + logger.info("Using username/password authentication") 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 + raise ValueError( + "No credentials found: set --token-name/--token-value " + "(or TABLEAU_TOKEN_NAME/TABLEAU_TOKEN) or --username/--password " + "(or TABLEAU_USERNAME/TABLEAU_PASSWORD)" ) - 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.") + if args.api_version: + server = TSC.Server(args.server, use_server_version=False) + server.version = args.api_version + else: + server = TSC.Server(args.server, use_server_version=True) + + return server, auth - # 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 - # 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" +def sample_connect_to_server(args: argparse.Namespace) -> TSC.Server: + """Resolve credentials, build the Server and Auth objects, and sign in. + Returns a Server that has an active session. The caller is responsible + for signing out (or use :func:`build_server_and_auth` and wrap the + sign-in yourself with a ``with`` block). + """ + resolve_credentials(args) + server, auth = build_server_and_auth(args) + identity = args.token_name or args.username + print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site or '(default)'}\nAs: {identity}") + server.auth.sign_in(auth) return server +def set_up_and_log_in() -> None: + parser = argparse.ArgumentParser(description="Log in to Tableau Server.") + sample_define_common_options(parser) + args = parser.parse_args() + + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) + + server = sample_connect_to_server(args) + info = server.server_info.get() + print(f"Product version: {info.product_version}") + print(f"REST API version: {server.version}") + print(f"Site: {server.site_id} User: {server.user_id}") + + if __name__ == "__main__": set_up_and_log_in() From 67d2d8818bf7401e23a2d0f3531633ada927414b Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 27 Aug 2026 13:02:38 -0700 Subject: [PATCH 2/2] docs: drop redundant 'they compose in this precedence order' line from login.py header --- samples/login.py | 1 - 1 file changed, 1 deletion(-) diff --git a/samples/login.py b/samples/login.py index 9f9e76926..7a09d5c7d 100644 --- a/samples/login.py +++ b/samples/login.py @@ -1,6 +1,5 @@ #### # This sample demonstrates three ways to supply Tableau credentials -- -# they compose in this precedence order: # # 1. Command-line arguments (highest precedence) # 2. TABLEAU_* environment variables