diff --git a/samples/_shared.py b/samples/_shared.py new file mode 100644 index 000000000..d24cdddc9 --- /dev/null +++ b/samples/_shared.py @@ -0,0 +1,267 @@ +#### +# 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). " + "When multiple auth options are set, JWT wins over PAT and PAT wins over " + "username/password (see build_auth in _shared.py and 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 + + # 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. Handle + # both directions of each pair so a user who set only the secret half + # (e.g. TABLEAU_TOKEN_VALUE without TABLEAU_TOKEN_NAME) is prompted for + # the non-secret half, not asked to re-type the secret they already have. + 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, "token_value", None) and not getattr(args, "token_name", None): + args.token_name = input("Personal access token name: ").strip() + return + if getattr(args, "username", None) and not getattr(args, "password", None): + args.password = getpass.getpass(f"Password for '{args.username}': ") + return + if getattr(args, "password", None) and not getattr(args, "username", None): + args.username = input("Username: ").strip() + 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. + + Also validates that `--server` is set. `resolve_credentials` skips prompting + in non-interactive contexts (CI, piped stdin), so a missing server URL would + otherwise reach `TSC.Server(None, ...)` and fail with a confusing error; + catching it here gives the caller a clear message. + """ + if not getattr(args, "server", None): + raise ValueError( + "No Tableau server URL. Provide --server, set the TABLEAU_SERVER env " + "var, or run in an interactive terminal to be prompted." + ) + 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." + ) diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index c9f35d5be..88ac5ec31 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -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: @@ -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: diff --git a/samples/explore_favorites.py b/samples/explore_favorites.py index f199522ed..aecd06685 100644 --- a/samples/explore_favorites.py +++ b/samples/explore_favorites.py @@ -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) @@ -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]) @@ -59,25 +49,32 @@ 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}") + # Cleanup — delete the favorites we just created. Must stay inside the + # `with server.auth.sign_in(...)` block; a delete after sign-out fails + # with a not-signed-in error. Each guard mirrors the "add" check above + # so we do not try to delete a favorite we never created. + if my_workbook is not None: + 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}") + if my_view is not None: + server.favorites.delete_favorite_view(user, my_view) + print(f"View deleted from favorites. View Name: {my_view.name}, View ID: {my_view.id}") - 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 + ) + ) diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index f25c41849..64a73a430 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -11,37 +11,25 @@ import argparse import logging -import os.path import tableauserverclient as TSC +from _shared import add_common_arguments, build_auth, resolve_credentials + def main(): parser = argparse.ArgumentParser(description="Explore webhook 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("--create", help="create a webhook") parser.add_argument("--delete", help="delete a webhook", action="store_true") 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): # Create webhook if create flag is set (-create, -c) @@ -54,9 +42,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..033dbe594 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -15,21 +15,12 @@ import tableauserverclient as TSC +from _shared import add_common_arguments, build_auth, resolve_credentials + def main(): parser = argparse.ArgumentParser(description="Explore workbook 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 workbook to publish") parser.add_argument("--download", metavar="FILEPATH", help="path to save downloaded workbook") @@ -42,19 +33,18 @@ def main(): 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): # 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 +53,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 +115,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..dfc4541df 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -5,42 +5,31 @@ import argparse import logging -import os.path import tableauserverclient as TSC +from _shared import add_common_arguments, build_auth, resolve_credentials + def main(): parser = argparse.ArgumentParser(description="Explore extract 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", help="site name") - parser.add_argument("--token-name", "-tn", help="name of the personal access token used 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( - "--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("--create", action="store_true") parser.add_argument("--delete", action="store_true") parser.add_argument("--refresh", action="store_true") - parser.add_argument("--workbook", required=False) - parser.add_argument("--datasource", required=False) + # --workbook / --datasource are mutually exclusive; if neither is passed we + # fall back to picking the first workbook on the site (see below). + target = parser.add_mutually_exclusive_group() + target.add_argument("--workbook") + target.add_argument("--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) - server = TSC.Server(args.server, use_server_version=False) - server.add_http_options({"verify": False}) - server.use_server_version() + tableau_auth = build_auth(args) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): wb = None ds = None @@ -53,19 +42,25 @@ 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: - # Pick one workbook from the list - wb = all_workbooks[3] + # Fall back to the first workbook on the site. For a real run, + # pass --workbook for a workbook you know has an extract. + wb = all_workbooks[0] if args.create: - print("create extract on wb ", wb.name) - extract_job = server.workbooks.create_extract(wb, includeAll=True) - print(extract_job) + if wb is None: + print("no workbook selected to create an extract on") + else: + print(f"create extract on workbook {wb.name}") + extract_job = server.workbooks.create_extract(wb, includeAll=True) + print(extract_job) if args.refresh: extract_job = None @@ -81,9 +76,12 @@ def main(): print(extract_job) if args.delete: - print("delete extract on wb ", wb.name) - jj = server.workbooks.delete_extract(wb) - print(jj) + if wb is None: + print("no workbook selected to delete an extract from") + else: + print(f"delete extract on workbook {wb.name}") + jj = server.workbooks.delete_extract(wb) + print(jj) if __name__ == "__main__": 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/list_jobs.py b/samples/list_jobs.py new file mode 100644 index 000000000..b72d6a7b5 --- /dev/null +++ b/samples/list_jobs.py @@ -0,0 +1,141 @@ +#### +# 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.10 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. + # Pass the tz-aware datetime directly; QuerySet serializes it as UTC + # with a trailing `Z`, which older Tableau Server versions require. + # A raw isoformat() string can end up with a `+00:00` offset that + # those versions reject. + query = query.filter(created_at__gte=cutoff) + + 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 JobCancelledException: + # JobCancelledException is a subclass of JobFailedException, so this + # branch must come first or cancelled jobs get reported as failed + # with the wrong exit code. + print(f"Job {job_id} was cancelled.") + raise SystemExit(2) + 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 + + print(f"Job {job_id} finished. finish_code={job.finish_code} notes={job.notes}") + + +if __name__ == "__main__": + main() diff --git a/samples/login.py b/samples/login.py index bc99385b3..13e05294f 100644 --- a/samples/login.py +++ b/samples/login.py @@ -1,83 +1,48 @@ #### # This script demonstrates how to log in to Tableau Server Client. # -# To run the script, you must have installed Python 3.7 or later. +# To run the script, you must have installed Python 3.10 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, TABLEAU_JWT, TABLEAU_JWT_FILE), from a +# `.env` file in the current working directory (or samples/, or repo root), +# 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}") - + tableau_auth = build_auth(args) + if isinstance(tableau_auth, TSC.JWTAuth): + identifier = "JWT (Connected App)" + elif isinstance(tableau_auth, TSC.PersonalAccessTokenAuth): + identifier = f"Token name: {args.token_name}" 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 - ) - 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.") + identifier = f"Username: {args.username}" + print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\n{identifier}") # 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 +50,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/manage_subscriptions.py b/samples/manage_subscriptions.py new file mode 100644 index 000000000..e8d094db3 --- /dev/null +++ b/samples/manage_subscriptions.py @@ -0,0 +1,135 @@ +#### +# 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.10 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.", + ) + # BooleanOptionalAction (Python 3.9+) gives us --attach-image / --no-attach-image + # so users can opt out of the default PNG snapshot. Same for the PDF pair for + # symmetry, even though its default is False. + create_p.add_argument( + "--attach-image", + action=argparse.BooleanOptionalAction, + default=True, + help="Attach a PNG snapshot (default: on; pass --no-attach-image to disable).", + ) + create_p.add_argument( + "--attach-pdf", + action=argparse.BooleanOptionalAction, + default=False, + help="Also attach a PDF snapshot (default: off).", + ) + 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() diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index e82c75cf9..c255d1dfa 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -4,7 +4,7 @@ # a workbook that matches a given name, download the workbook, # and then publish it to the destination site. # -# To run the script, you must have installed Python 3.7 or later. +# To run the script, you must have installed Python 3.10 or later. #### import argparse @@ -14,37 +14,27 @@ import tableauserverclient as TSC +from _shared import add_common_arguments, build_auth, resolve_credentials + def main(): parser = argparse.ArgumentParser( - description="Move one workbook from the" - "default project of the default site to" - "the default project of another site." - ) - # 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)", + description=( + "Move one workbook from the default project of the default site " "to the default project of another site." + ) ) + add_common_arguments(parser) # Options specific to this sample parser.add_argument("--workbook-name", "-w", help="name of workbook to move") parser.add_argument("--destination-site", "-d", help="name of site to move workbook into") 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())) # Step 1: Sign in to both sites on server - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + tableau_auth = build_auth(args) source_server = TSC.Server(args.server) dest_server = TSC.Server(args.server) @@ -65,10 +55,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_datasource.py b/samples/publish_datasource.py index c674e6882..753f5a5f9 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -11,41 +11,29 @@ # For more information, refer to the documentations: # (https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_datasources.htm#publish_data_source) # -# For signing into server, this script uses personal access tokens. For -# more information on personal access tokens, refer to the documentations: +# Sign-in delegates to `build_auth()` in samples/_shared.py, which accepts +# JWT (Connected App), personal access token, or username + password (JWT +# wins over PAT wins over username/password when more than one is set). +# For PATs specifically, see: # (https://help.tableau.com/current/server/en-us/security_personal_access_tokens.htm) # -# To run the script, you must have installed Python 3.7 or later. +# To run the script, you must have installed Python 3.10 or later. #### 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 +44,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 @@ -92,6 +68,8 @@ def main(): TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, args.project) ) projects = list(TSC.Pager(server.projects, req_options)) + if not projects: + raise ValueError(f"No project named {args.project!r} on this site.") if len(projects) > 1: raise ValueError("The project name is not unique") project_id = projects[0].id @@ -123,11 +101,8 @@ def main(): new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds ) print( - ( - "{}Datasource published. Datasource ID: {}".format( - new_datasource.id, tableauserverclient.datetime_helpers.timestamp() - ) - ) + f"[{tableauserverclient.datetime_helpers.timestamp()}] " + f"Datasource published. Datasource ID: {new_datasource.id}" ) print("\t\tClosing connection") diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 077ddaddd..b83ad48cd 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -11,7 +11,7 @@ # For more information, refer to the documentations on 'Publish Workbook' # (https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm) # -# To run the script, you must have installed Python 3.7 or later. +# To run the script, you must have installed Python 3.10 or later. #### import argparse @@ -20,24 +20,20 @@ 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") + # `-u` is already taken by --username in add_common_arguments; use `-U` here + # so argparse does not raise a conflicting-option-string error when the + # parser is built at run time (inside main()). + group.add_argument("--thumbnails-user-id", "-U", help="User ID to use for thumbnails") group.add_argument("--thumbnails-group-id", "-g", help="Group ID to use for thumbnails") parser.add_argument("--workbook-name", "-n", help="Name with which to publish the workbook") @@ -49,13 +45,15 @@ 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) - server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False}) + tableau_auth = build_auth(args) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): # Step2: Retrieve the project id, if a project name was passed if args.project is not None: @@ -64,13 +62,18 @@ def main(): TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, args.project) ) projects = list(TSC.Pager(server.projects, req_options)) + if not projects: + raise ValueError(f"No project named {args.project!r} on this site.") if len(projects) > 1: raise ValueError("The project name is not unique") 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..2fd33a1ec 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -2,7 +2,7 @@ # This script demonstrates how to use the Tableau Server Client # to query extract refresh tasks and run them as needed. # -# To run the script, you must have installed Python 3.7 or later. +# To run the script, you must have installed Python 3.10 or later. #### import argparse @@ -10,6 +10,8 @@ import tableauserverclient as TSC +from _shared import add_common_arguments, build_auth, resolve_credentials + def handle_run(server, args): task = server.tasks.get_by_id(args.id) @@ -17,8 +19,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}") @@ -29,20 +31,9 @@ def handle_info(server, args): def main(): parser = argparse.ArgumentParser(description="Get all of the refresh tasks available on a 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)", - ) + add_common_arguments(parser) # Options specific to this sample - subcommands = parser.add_subparsers() + subcommands = parser.add_subparsers(dest="command", required=True) list_arguments = subcommands.add_parser("list") list_arguments.set_defaults(func=handle_list) @@ -57,12 +48,10 @@ def main(): 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): args.func(server, args) diff --git a/samples/update_workbook_data_freshness_policy.py b/samples/update_workbook_data_freshness_policy.py index c23e3717f..297335200 100644 --- a/samples/update_workbook_data_freshness_policy.py +++ b/samples/update_workbook_data_freshness_policy.py @@ -2,7 +2,7 @@ # This script demonstrates how to update workbook data freshness policy using the Tableau # Server Client. # -# To run the script, you must have installed Python 3.7 or later. +# To run the script, you must have installed Python 3.10 or later. #### @@ -12,47 +12,39 @@ import tableauserverclient as TSC from tableauserverclient import IntervalItem +from _shared import add_common_arguments, build_auth, resolve_credentials + def main(): - parser = argparse.ArgumentParser(description="Creates sample schedules for each type of frequency.") - # 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)", + parser = argparse.ArgumentParser( + description="Update a workbook's data freshness policy across the supported schedule types." ) + add_common_arguments(parser) # Options specific to this sample: # This sample has no additional options, yet. If you add some, please add them here 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())) - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=False) - server.add_http_options({"verify": False}) - server.use_server_version() + tableau_auth = build_auth(args) + server = TSC.Server(args.server, use_server_version=True) 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. + _, 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: - # Pick 1 workbook that has live datasource connection. - # Assuming 1st workbook met the criteria for sample purposes - # Data Freshness Policy is not available on extract & file-based datasource. - sample_workbook = all_workbooks[2] + # Pick 1 workbook that has a live datasource connection. Data + # freshness policy is not available on extract or file-based + # datasources, so this sample will print a warning below if the + # chosen workbook has none. Adjust the index (or add a lookup by + # name) for a workbook on your site with a live connection. + sample_workbook = all_workbooks[0] # Get more info from the workbook selected # Troubleshoot: if sample_workbook_extended.data_freshness_policy.option returns with AttributeError