diff --git a/.gitattributes b/.gitattributes index 040321c04..d4cabf8c3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,6 @@ tableauserverclient/_version.py export-subst tableauserverclient/bin/_version.py export-subst + +# Normalize line endings to LF to prevent CRLF from creeping into Python files +* text=auto +*.py text eol=lf diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml index 554febf64..abc3714dc 100644 --- a/.github/workflows/meta-checks.yml +++ b/.github/workflows/meta-checks.yml @@ -8,7 +8,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.10'] + python-version: ['3.13'] runs-on: ${{ matrix.os }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 4fdd6e09c..576a09926 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,4 +1,4 @@ -name: Mark stale issues and pull requests +name: Stale issues and pull requests on: schedule: @@ -8,6 +8,7 @@ on: permissions: issues: write pull-requests: write + actions: write jobs: stale: @@ -25,6 +26,8 @@ jobs: days-before-pr-stale: 90 days-before-pr-close: 14 + operations-per-run: 300 + close-issue-reason: 'not_planned' stale-issue-message: > diff --git a/CHANGELOG.md b/CHANGELOG.md index 943436b27..5e3985bd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,27 @@ ## Unreleased +* Added support for "On Extract Refresh" subscriptions. These are Tableau + Cloud subscriptions that fire when a referenced extract-refresh schedule + completes, rather than on a time trigger, so recipients always get the + freshest data. New `SubscriptionItem.on_extract_refresh(subject, + extract_refresh_schedule_id, user_id, target)` classmethod is the + recommended way to construct them, and `SubscriptionItem.refresh_extract_triggered` + is a boolean property that reflects the `refreshExtractTriggered` + attribute on the wire. `subscriptions.create()` and `.update()` now + raise `ValueError` if `schedule_id` is missing (previously a confusing + server-side error). Fixes #1658. * Added `Projects.get_by_path(path)` to look up a project by its slash-separated hierarchy path (e.g. `"Marketing/Q1 Reports"`). The walk is performed level by level using the REST API name filter, so a path with *n* components issues *n* requests. Returns the matching `ProjectItem` or `None` if no project is found. +* Added `JobItem.status_notes` for the structured `` block documented on the Query + Job REST endpoint. Populated for UserImport and other multi-row jobs where + individual rows have distinct outcomes; each entry is a dict with keys + `type` / `value` / `text`. The existing `notes: list[str]` attribute is + unchanged (it parses the separate legacy `` element still emitted by + some job types). Fixes #1850. ## 0.18.0 (6 April 2022) * Switched to using defused_xml for xml attack protection diff --git a/samples/_shared.py b/samples/_shared.py new file mode 100644 index 000000000..686e852c1 --- /dev/null +++ b/samples/_shared.py @@ -0,0 +1,247 @@ +#### +# Shared helpers for the sample scripts in this directory. +# +# The most important thing here is `resolve_credentials`, which lets samples +# accept a Tableau server URL, site, and credentials from three sources: +# +# 1. Command-line arguments (useful for CI, but note that these end up in +# shell history and process listings, so avoid them for real secrets). +# 2. Environment variables. We look for a `.env` file in the current +# working directory, in the samples/ directory, and at the repository +# root, in that order, and load whichever we find first -- only the +# standard `KEY=value` lines, no external dependency required. +# 3. Interactive prompts. Missing values are asked for on stdin when +# stdin is a terminal; secrets are read with `getpass.getpass` so they +# are not echoed. In non-interactive contexts (CI, piped input) we skip +# the prompts and let `build_auth` raise instead of hanging on `input()`. +# +# CLI args take precedence, then environment, then interactive prompt. +# This lets a user set defaults in a `.env` file and override individual +# values on the command line. +# +# Sign-in short flags follow the tabcmd convention (-s server, -t site, +# -u username, -p password). --token-name and --token-value do not have +# short flags because tabcmd does not either and re-using a letter here +# would silently accept a token as a password on old command lines. +#### + +from __future__ import annotations + +import argparse +import getpass +import os +import sys +from pathlib import Path +from typing import Iterable + +import tableauserverclient as TSC + +# Recognized environment variable names, in the order we look them up. +# Older samples used TABLEAU_SERVER etc; keep those working as aliases. +_ENV_ALIASES: dict[str, tuple[str, ...]] = { + "server": ("TABLEAU_SERVER", "SERVER"), + "site": ("TABLEAU_SITE", "SITE"), + "token_name": ("TABLEAU_TOKEN_NAME", "TOKEN_NAME"), + "token_value": ("TABLEAU_TOKEN_VALUE", "TOKEN_VALUE"), + "username": ("TABLEAU_USERNAME", "USERNAME"), + "password": ("TABLEAU_PASSWORD", "PASSWORD"), + "jwt": ("TABLEAU_JWT", "JWT"), + "jwt_file": ("TABLEAU_JWT_FILE", "JWT_FILE"), +} + + +def add_common_arguments(parser: argparse.ArgumentParser) -> None: + """Add the sign-in and logging arguments used by every sample. + + Short flags follow the tabcmd convention: -s server, -t site, + -u username, -p password, -l logging-level. --token-name / + --token-value and --jwt / --jwt-file intentionally have no short + flag; re-using letters here risked silently accepting a token as + a password on scripts that pre-date the shared helper. All args + are optional; missing values are pulled from the environment or + prompted for interactively. + """ + parser.add_argument("--server", "-s", help="server address (env: TABLEAU_SERVER)") + parser.add_argument("--site", "-t", help="site content URL (env: TABLEAU_SITE)") + parser.add_argument( + "--token-name", + help="name of the personal access token used to sign into the server " "(env: TABLEAU_TOKEN_NAME)", + ) + parser.add_argument( + "--token-value", + help="value of the personal access token used to sign into the server " + "(env: TABLEAU_TOKEN_VALUE). Prefer the env var or interactive prompt over the " + "command line so the secret does not land in shell history.", + ) + parser.add_argument( + "--username", + "-u", + help="username to sign into the server (env: TABLEAU_USERNAME). Only used if " + "no personal access token or JWT is supplied.", + ) + parser.add_argument( + "--password", + "-p", + help="password (env: TABLEAU_PASSWORD). Prefer the env var or interactive " "prompt over the command line.", + ) + parser.add_argument( + "--jwt", + help="encoded JSON Web Token for Connected-App sign-in (env: TABLEAU_JWT). " + "Mutually exclusive with token/username auth; see JWTAuth in the docs.", + ) + parser.add_argument( + "--jwt-file", + help="path to a file whose contents are the encoded JWT (env: TABLEAU_JWT_FILE). " + "Useful for pipelines that mint a JWT into a file rather than an env var.", + ) + parser.add_argument( + "--env-file", + help="path to a .env-style file with KEY=value lines to load. If omitted, " + ".env is looked for in the current directory, the samples/ directory, and " + "the repository root, and the first one found is loaded.", + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) + + +def _load_env_file(path: Path) -> None: + """Very small `.env` loader: `KEY=value` per line, `#` for comments. + + We do not want a runtime dependency on python-dotenv for the samples, + so this parses just the common cases. Existing env vars are not + overwritten -- a value already in `os.environ` wins. + """ + try: + text = path.read_text(encoding="utf-8") + except OSError: + return + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip("'\"") + if key and key not in os.environ: + os.environ[key] = value + + +def _first_env(names: Iterable[str]) -> str | None: + for name in names: + val = os.environ.get(name) + if val: + return val + return None + + +def _candidate_env_paths() -> list[Path]: + """Locations we check for a .env file, in priority order. + + cwd first (so the invoker can override), then the directory that holds + this shared module (samples/), then the repository root one level up. + """ + module_dir = Path(__file__).resolve().parent + return [ + Path.cwd() / ".env", + module_dir / ".env", + module_dir.parent / ".env", + ] + + +def resolve_credentials(args: argparse.Namespace, *, allow_prompt: bool = True) -> None: + """Fill in server/site/credential values on `args` from env or prompt. + + Precedence for each field: existing value on `args` > environment variable + > interactive prompt (only when allow_prompt is true AND stdin is a TTY). + + Pass `allow_prompt=False`, or run with stdin redirected (CI, piped input), + to skip the prompts entirely; the caller should then verify the fields it + needs are set, or let `build_auth` raise a clear ValueError. + """ + # Load `.env` file if one is requested or available. + env_file = getattr(args, "env_file", None) + if env_file: + _load_env_file(Path(env_file)) + else: + for candidate in _candidate_env_paths(): + if candidate.is_file(): + _load_env_file(candidate) + break + + # For each field, prefer the CLI arg, then env, then prompt. + for field, env_names in _ENV_ALIASES.items(): + current = getattr(args, field, None) + if current: + continue + env_val = _first_env(env_names) + if env_val: + setattr(args, field, env_val) + + # If a JWT file was provided, read its contents into args.jwt (unless the + # caller also passed --jwt directly, in which case the direct value wins). + jwt_file = getattr(args, "jwt_file", None) + if jwt_file and not getattr(args, "jwt", None): + try: + args.jwt = Path(jwt_file).read_text(encoding="utf-8").strip() + except OSError as exc: + raise SystemExit(f"Could not read --jwt-file {jwt_file!r}: {exc}") from exc + + # Skip prompting entirely if the caller opted out or stdin is not a + # terminal. `input()` on a closed/piped stdin either blocks forever or + # raises EOFError; neither is what a scripted invocation wants. + if not allow_prompt or not sys.stdin.isatty(): + return + + # Prompt for what's still missing. We only prompt for the pieces we + # actually need: server URL, and one of JWT / token / username+password. + if not getattr(args, "server", None): + args.server = input("Tableau server URL: ").strip() + + # Site is optional (empty string is the default site) so we don't prompt. + + has_jwt = bool(getattr(args, "jwt", None)) + has_token = bool(getattr(args, "token_name", None) and getattr(args, "token_value", None)) + has_user = bool(getattr(args, "username", None) and getattr(args, "password", None)) + + if has_jwt or has_token or has_user: + return + + # Partial info supplied -- fill in the matching missing piece. + if getattr(args, "token_name", None) and not getattr(args, "token_value", None): + args.token_value = getpass.getpass(f"Personal access token value for '{args.token_name}': ") + return + if getattr(args, "username", None) and not getattr(args, "password", None): + args.password = getpass.getpass(f"Password for '{args.username}': ") + return + + # Fully unspecified: default to PAT since that's what the docs recommend. + print("No credentials found in args or environment. Sign in with a personal access token.") + print("(Set TABLEAU_TOKEN_NAME / TABLEAU_TOKEN_VALUE in your env or a .env file to skip this prompt.)") + args.token_name = input("Personal access token name: ").strip() + args.token_value = getpass.getpass("Personal access token value: ") + + +def build_auth(args: argparse.Namespace) -> TSC.TableauAuth | TSC.PersonalAccessTokenAuth | TSC.JWTAuth: + """Return the appropriate auth object based on what's set on `args`. + + Priority is JWT > PAT > username/password: a script that has a JWT + minted for a specific session should never fall back to a longer-lived + credential if the JWT-adjacent fields were left set by accident. + """ + site = getattr(args, "site", None) or "" + if getattr(args, "jwt", None): + return TSC.JWTAuth(args.jwt, site_id=site) + if getattr(args, "token_name", None) and getattr(args, "token_value", None): + return TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=site) + if getattr(args, "username", None) and getattr(args, "password", None): + return TSC.TableauAuth(args.username, args.password, site_id=site) + raise ValueError( + "No usable credentials found. Provide --jwt/--jwt-file, " + "--token-name/--token-value, --username/--password, or set the " + "corresponding env vars." + ) diff --git a/samples/create_extract_refresh_subscription.py b/samples/create_extract_refresh_subscription.py new file mode 100644 index 000000000..ae032a0bd --- /dev/null +++ b/samples/create_extract_refresh_subscription.py @@ -0,0 +1,108 @@ +#### +# This script creates a Tableau Cloud "On Extract Refresh" subscription: +# a subscription that fires when an extract-refresh schedule completes, +# rather than on the schedule's time trigger. Recipients get the email +# alongside the refresh, so they always see the freshest data. +# +# What it does: +# 1. Sign in. +# 2. Look up the target view or workbook by name. +# 3. List extract-refresh schedules and pick the one you named. +# 4. Build the subscription via SubscriptionItem.on_extract_refresh(). +# 5. Call subscriptions.create() and print the new subscription id. +# +# On Tableau Server this same script works as long as the schedule you +# reference is an extract-refresh schedule; the "On Extract Refresh" +# terminology is Cloud-UI-specific but the REST attribute +# (refreshExtractTriggered) is the same on both. +# +# Requires Python 3.10 or later. +#### + + +import argparse +import logging + +import tableauserverclient as TSC + + +def usage(args): + parser = argparse.ArgumentParser(description="Create an On Extract Refresh subscription for a view or workbook.") + # Common options; keep in sync across samples. + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", default="", help="site content URL") + parser.add_argument("--token-name", "-p", required=True, help="personal access token name") + parser.add_argument("--token-value", "-v", required=True, help="personal access token value") + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + ) + # Sample-specific options. + parser.add_argument("--subject", required=True, help="subscription subject line") + parser.add_argument("--schedule", required=True, help="name of the extract-refresh schedule to attach to") + parser.add_argument("--user", required=True, help="username of the subscription recipient") + target = parser.add_mutually_exclusive_group(required=True) + target.add_argument("--view", help="name of the view to send") + target.add_argument("--workbook", help="name of the workbook to send") + return parser.parse_args(args) + + +def _find_one(items, label, name): + matches = [i for i in items if i.name == name] + if len(matches) != 1: + raise SystemExit(f"expected exactly one {label} named {name!r}, found {len(matches)}") + return matches[0] + + +def find_extract_refresh_schedule(server, name): + schedules = [s for s in TSC.Pager(server.schedules) if s.schedule_type == TSC.ScheduleItem.Type.Extract] + return _find_one(schedules, "extract-refresh schedule", name) + + +def find_view(server, name): + return _find_one(list(TSC.Pager(server.views)), "view", name) + + +def find_workbook(server, name): + return _find_one(list(TSC.Pager(server.workbooks)), "workbook", name) + + +def find_user(server, name): + return _find_one(list(TSC.Pager(server.users)), "user", name) + + +def run(args): + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) + + auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(auth): + schedule = find_extract_refresh_schedule(server, args.schedule) + user = find_user(server, args.user) + if args.view: + content = find_view(server, args.view) + target = TSC.Target(content.id, "view") + else: + content = find_workbook(server, args.workbook) + target = TSC.Target(content.id, "workbook") + + subscription = TSC.SubscriptionItem.on_extract_refresh( + subject=args.subject, + extract_refresh_schedule_id=schedule.id, + user_id=user.id, + target=target, + ) + created = server.subscriptions.create(subscription) + print(f"Created subscription {created.id}: {created.subject!r} on schedule {schedule.name!r}") + + +def main(): + import sys + + run(usage(sys.argv[1:])) + + +if __name__ == "__main__": + main() 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..2ad174f7c 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,15 +49,15 @@ 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}") @@ -75,9 +65,10 @@ def main(): 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/export.py b/samples/export.py index c7f1cdb06..3200d491f 100644 --- a/samples/export.py +++ b/samples/export.py @@ -1,107 +1,107 @@ -#### -# This script demonstrates how to export a view using the Tableau -# Server Client. -# -# To run the script, you must have installed Python 3.7 or later. -#### - -import argparse -import logging - -import tableauserverclient as TSC - - -def main(): - parser = argparse.ArgumentParser(description="Export a view as an image, PDF, or CSV") - # 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)", - ) - # Options specific to this sample - group = parser.add_mutually_exclusive_group(required=True) - group.add_argument( - "--pdf", dest="type", action="store_const", const=("populate_pdf", "PDFRequestOptions", "pdf", "pdf") - ) - group.add_argument( - "--png", dest="type", action="store_const", const=("populate_image", "ImageRequestOptions", "image", "png") - ) - group.add_argument( - "--csv", dest="type", action="store_const", const=("populate_csv", "CSVRequestOptions", "csv", "csv") - ) - # other options shown in explore_workbooks: workbook.download, workbook.preview_image - parser.add_argument( - "--language", help="Text such as 'Average' will appear in this language. Use values like fr, de, es, en" - ) - parser.add_argument("--workbook", action="store_true") - parser.add_argument("--custom_view", action="store_true") - - parser.add_argument("--file", "-f", help="filename to store the exported data") - parser.add_argument("--filter", "-vf", metavar="COLUMN:VALUE", help="View filter to apply to the view") - parser.add_argument("resource_id", help="LUID for the view or workbook") - - 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) - - 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}) - with server.auth.sign_in(tableau_auth): - print("Connected") - if args.workbook: - item = server.workbooks.get_by_id(args.resource_id) - elif args.custom_view: - item = server.custom_views.get_by_id(args.resource_id) - else: - item = server.views.get_by_id(args.resource_id) - - if not item: - print(f"No item found for id {args.resource_id}") - exit(1) - - print(f"Item found: {item.name}") - # We have a number of different types and functions for each different export type. - # We encode that information above in the const=(...) parameter to the add_argument function to make - # the code automatically adapt for the type of export the user is doing. - # We unroll that information into methods we can call, or objects we can create by using getattr() - populate_func_name, option_factory_name, member_name, extension = args.type - populate = getattr(server.views, populate_func_name) - if args.workbook: - populate = getattr(server.workbooks, populate_func_name) - elif args.custom_view: - populate = getattr(server.custom_views, populate_func_name) - - option_factory = getattr(TSC, option_factory_name) - options: TSC.PDFRequestOptions = option_factory() - - if args.filter: - options = options.vf(*args.filter.split(":")) - - if args.language: - options.language = args.language - - if args.file: - filename = args.file - else: - filename = f"out-{options.language}.{extension}" - - populate(item, options) - with open(filename, "wb") as f: - if member_name == "csv": - f.writelines(getattr(item, member_name)) - else: - f.write(getattr(item, member_name)) - print("saved to " + filename) - - -if __name__ == "__main__": - main() +#### +# This script demonstrates how to export a view using the Tableau +# Server Client. +# +# To run the script, you must have installed Python 3.7 or later. +#### + +import argparse +import logging + +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description="Export a view as an image, PDF, or CSV") + # 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)", + ) + # Options specific to this sample + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument( + "--pdf", dest="type", action="store_const", const=("populate_pdf", "PDFRequestOptions", "pdf", "pdf") + ) + group.add_argument( + "--png", dest="type", action="store_const", const=("populate_image", "ImageRequestOptions", "image", "png") + ) + group.add_argument( + "--csv", dest="type", action="store_const", const=("populate_csv", "CSVRequestOptions", "csv", "csv") + ) + # other options shown in explore_workbooks: workbook.download, workbook.preview_image + parser.add_argument( + "--language", help="Text such as 'Average' will appear in this language. Use values like fr, de, es, en" + ) + parser.add_argument("--workbook", action="store_true") + parser.add_argument("--custom_view", action="store_true") + + parser.add_argument("--file", "-f", help="filename to store the exported data") + parser.add_argument("--filter", "-vf", metavar="COLUMN:VALUE", help="View filter to apply to the view") + parser.add_argument("resource_id", help="LUID for the view or workbook") + + 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) + + 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}) + with server.auth.sign_in(tableau_auth): + print("Connected") + if args.workbook: + item = server.workbooks.get_by_id(args.resource_id) + elif args.custom_view: + item = server.custom_views.get_by_id(args.resource_id) + else: + item = server.views.get_by_id(args.resource_id) + + if not item: + print(f"No item found for id {args.resource_id}") + exit(1) + + print(f"Item found: {item.name}") + # We have a number of different types and functions for each different export type. + # We encode that information above in the const=(...) parameter to the add_argument function to make + # the code automatically adapt for the type of export the user is doing. + # We unroll that information into methods we can call, or objects we can create by using getattr() + populate_func_name, option_factory_name, member_name, extension = args.type + populate = getattr(server.views, populate_func_name) + if args.workbook: + populate = getattr(server.workbooks, populate_func_name) + elif args.custom_view: + populate = getattr(server.custom_views, populate_func_name) + + option_factory = getattr(TSC, option_factory_name) + options: TSC.PDFRequestOptions = option_factory() + + if args.filter: + options = options.vf(*args.filter.split(":")) + + if args.language: + options.language = args.language + + if args.file: + filename = args.file + else: + filename = f"out-{options.language}.{extension}" + + populate(item, options) + with open(filename, "wb") as f: + if member_name == "csv": + f.writelines(getattr(item, member_name)) + else: + f.write(getattr(item, member_name)) + print("saved to " + filename) + + +if __name__ == "__main__": + main() diff --git a/samples/extracts.py b/samples/extracts.py index d9289452a..88ef0e382 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -5,25 +5,15 @@ 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") @@ -32,15 +22,11 @@ def main(): parser.add_argument("--datasource", required=False) 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,9 +39,11 @@ 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: 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..32ff5e7c8 --- /dev/null +++ b/samples/list_jobs.py @@ -0,0 +1,137 @@ +#### +# 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. + query = query.filter(created_at__gte=cutoff.isoformat()) + + 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..aa9acd6ab --- /dev/null +++ b/samples/manage_subscriptions.py @@ -0,0 +1,171 @@ +#### +# 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" +# +# # Create an "On Extract Refresh" subscription (fires when the referenced +# # extract-refresh schedule completes, rather than on the schedule's time +# # trigger). --schedule-id must reference an extract-refresh schedule. +# python samples/manage_subscriptions.py create \ +# --target-type view \ +# --target-id \ +# --schedule-id \ +# --subject "Snapshot when refresh finishes" \ +# --on-extract-refresh +# +# # 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()) + + if args.on_extract_refresh: + # Extract-refresh-triggered: the subscription fires when the referenced + # extract-refresh schedule finishes running the refresh. On Tableau + # Cloud this shows up as schedule type "On Extract Refresh" in the UI. + # `SubscriptionItem.on_extract_refresh` wires up schedule_id and the + # refreshExtractTriggered flag together so the server accepts the + # payload; --schedule-id must reference an extract-refresh schedule. + new_sub = TSC.SubscriptionItem.on_extract_refresh( + subject=args.subject, + extract_refresh_schedule_id=args.schedule_id, + user_id=user_id, + target=target, + ) + else: + 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) + trigger = "on-extract-refresh" if args.on_extract_refresh else "on-schedule" + print(f"Created {trigger} subscription {created.id} " f"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.add_argument( + "--on-extract-refresh", + action="store_true", + default=False, + help=( + "Fire this subscription when the referenced extract-refresh schedule " + "completes, rather than on the schedule's time trigger. --schedule-id " + "must reference an extract-refresh schedule (see create_extract_refresh_" + "subscription.py for the fully worked example)." + ), + ) + 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..e4740d8c7 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,6 +14,8 @@ import tableauserverclient as TSC +from _shared import add_common_arguments, build_auth, resolve_credentials + def main(): parser = argparse.ArgumentParser( @@ -21,30 +23,18 @@ def main(): "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)", - ) + 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..b2648e12c 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -15,37 +15,23 @@ # more information on personal access tokens, refer to the documentations: # (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 +42,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 diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 077ddaddd..338b4aa28 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,19 @@ 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 at import. + 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 +44,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: @@ -69,8 +66,11 @@ def main(): 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..15d97bab6 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,18 +31,7 @@ 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() @@ -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/smoke_test.py b/samples/smoke_test.py index 237257014..778686abf 100644 --- a/samples/smoke_test.py +++ b/samples/smoke_test.py @@ -1,15 +1,15 @@ -# This sample verifies that tableau server client is installed -# and you can run it. It also shows the version of the client. - -import logging -import tableauserverclient as TSC - -logger = logging.getLogger("Sample") -logger.setLevel(logging.DEBUG) -logger.addHandler(logging.StreamHandler()) - - -server = TSC.Server("Fake-Server-Url", use_server_version=False) -print("Client details:") -logger.info(server.server_address) -logger.debug(TSC.server.endpoint.Endpoint.set_user_agent({})) +# This sample verifies that tableau server client is installed +# and you can run it. It also shows the version of the client. + +import logging +import tableauserverclient as TSC + +logger = logging.getLogger("Sample") +logger.setLevel(logging.DEBUG) +logger.addHandler(logging.StreamHandler()) + + +server = TSC.Server("Fake-Server-Url", use_server_version=False) +print("Client details:") +logger.info(server.server_address) +logger.debug(TSC.server.endpoint.Endpoint.set_user_agent({})) diff --git a/samples/update_workbook_data_freshness_policy.py b/samples/update_workbook_data_freshness_policy.py index c23e3717f..ffce18b1b 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,40 +12,28 @@ 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)", - ) + 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. + 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: diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index e18db4c78..7beb0276e 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -1,44 +1,44 @@ -import datetime - -ZERO = datetime.timedelta(0) -HOUR = datetime.timedelta(hours=1) - - -def timestamp(): - return datetime.datetime.now().strftime("%H:%M:%S") - - -# This class is a concrete implementation of the abstract base class tzinfo -# docs: https://docs.python.org/2.3/lib/datetime-tzinfo.html -class UTC(datetime.tzinfo): - """UTC""" - - def utcoffset(self, dt): - return ZERO - - def tzname(self, dt): - return "UTC" - - def dst(self, dt): - return ZERO - - -utc = UTC() -TABLEAU_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" - - -def parse_datetime(date): - if date is None: - return None - - try: - return datetime.datetime.strptime(date, TABLEAU_DATE_FORMAT).replace(tzinfo=utc) - except ValueError: - return None - - -def format_datetime(date): - if date is None: - return None - - return date.astimezone(tz=utc).strftime(TABLEAU_DATE_FORMAT) +import datetime + +ZERO = datetime.timedelta(0) +HOUR = datetime.timedelta(hours=1) + + +def timestamp(): + return datetime.datetime.now().strftime("%H:%M:%S") + + +# This class is a concrete implementation of the abstract base class tzinfo +# docs: https://docs.python.org/2.3/lib/datetime-tzinfo.html +class UTC(datetime.tzinfo): + """UTC""" + + def utcoffset(self, dt): + return ZERO + + def tzname(self, dt): + return "UTC" + + def dst(self, dt): + return ZERO + + +utc = UTC() +TABLEAU_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" + + +def parse_datetime(date): + if date is None: + return None + + try: + return datetime.datetime.strptime(date, TABLEAU_DATE_FORMAT).replace(tzinfo=utc) + except ValueError: + return None + + +def format_datetime(date): + if date is None: + return None + + return date.astimezone(tz=utc).strftime(TABLEAU_DATE_FORMAT) diff --git a/tableauserverclient/helpers/strings.py b/tableauserverclient/helpers/strings.py index a095d606e..b1c868cc7 100644 --- a/tableauserverclient/helpers/strings.py +++ b/tableauserverclient/helpers/strings.py @@ -1,67 +1,67 @@ -from defusedxml.ElementTree import fromstring, tostring -from functools import singledispatch -from typing import TypeVar, cast, overload - -# the redact method can handle either strings or bytes, but it can't mix them. -# Generic type so we can write the actual logic once, then use singledispatch to -# create the replacement text with the correct type -T = TypeVar("T", str, bytes) - - -def _redact_any_type(xml: T, sensitive_word: T, replacement: T, encoding=None) -> T: - try: - root = fromstring(xml) - matches = root.findall(".//*[@password]") - for item in matches: - item.attrib["password"] = "********" - matches = root.findall(".//password") - for item in matches: - item.text = "********" - # tostring returns bytes unless an encoding value is passed; cast since - # the callers pass encoding="unicode" for str and None for bytes - return cast(T, tostring(root, encoding=encoding)) - except Exception: - # something about the xml handling failed. Just cut off the text at the first occurrence of "password" - location = xml.find(sensitive_word) - return xml[:location] + replacement - - -@singledispatch -def redact_xml(content): - # this will only be called if it didn't get directed to the str or bytes overloads - raise TypeError("Redaction only works on xml saved as str or bytes") - - -@redact_xml.register -def _(xml: str) -> str: - out = _redact_any_type(xml, "password", "...[redacted]", encoding="unicode") - return out - - -@redact_xml.register # type: ignore[no-redef] -def _(xml: bytes) -> bytes: - return cast(bytes, _redact_any_type(xml, b"password", b"..[redacted]")) - - -@overload -def nullable_str_to_int(value: None) -> None: ... - - -@overload -def nullable_str_to_int(value: str) -> int: ... - - -def nullable_str_to_int(value): - return int(value) if value is not None else None - - -@overload -def nullable_str_to_bool(value: None) -> None: ... - - -@overload -def nullable_str_to_bool(value: str) -> bool: ... - - -def nullable_str_to_bool(value): - return str(value).lower() == "true" if value is not None else None +from defusedxml.ElementTree import fromstring, tostring +from functools import singledispatch +from typing import TypeVar, cast, overload + +# the redact method can handle either strings or bytes, but it can't mix them. +# Generic type so we can write the actual logic once, then use singledispatch to +# create the replacement text with the correct type +T = TypeVar("T", str, bytes) + + +def _redact_any_type(xml: T, sensitive_word: T, replacement: T, encoding=None) -> T: + try: + root = fromstring(xml) + matches = root.findall(".//*[@password]") + for item in matches: + item.attrib["password"] = "********" + matches = root.findall(".//password") + for item in matches: + item.text = "********" + # tostring returns bytes unless an encoding value is passed; cast since + # the callers pass encoding="unicode" for str and None for bytes + return cast(T, tostring(root, encoding=encoding)) + except Exception: + # something about the xml handling failed. Just cut off the text at the first occurrence of "password" + location = xml.find(sensitive_word) + return xml[:location] + replacement + + +@singledispatch +def redact_xml(content): + # this will only be called if it didn't get directed to the str or bytes overloads + raise TypeError("Redaction only works on xml saved as str or bytes") + + +@redact_xml.register +def _(xml: str) -> str: + out = _redact_any_type(xml, "password", "...[redacted]", encoding="unicode") + return out + + +@redact_xml.register # type: ignore[no-redef] +def _(xml: bytes) -> bytes: + return cast(bytes, _redact_any_type(xml, b"password", b"..[redacted]")) + + +@overload +def nullable_str_to_int(value: None) -> None: ... + + +@overload +def nullable_str_to_int(value: str) -> int: ... + + +def nullable_str_to_int(value): + return int(value) if value is not None else None + + +@overload +def nullable_str_to_bool(value: None) -> None: ... + + +@overload +def nullable_str_to_bool(value: str) -> bool: ... + + +def nullable_str_to_bool(value): + return str(value).lower() == "true" if value is not None else None diff --git a/tableauserverclient/models/column_item.py b/tableauserverclient/models/column_item.py index 739b7e8ef..dd4c1254f 100644 --- a/tableauserverclient/models/column_item.py +++ b/tableauserverclient/models/column_item.py @@ -1,71 +1,71 @@ -from defusedxml.ElementTree import fromstring - -from .property_decorators import property_not_empty - - -class ColumnItem: - def __init__(self, name, description=None): - self._id = None - self.description = description - self.name = name - - def __repr__(self): - return f"<{self.__class__.__name__} {self._id} {self.name} {self.description}>" - - @property - def id(self): - return self._id - - @property - def name(self): - return self._name - - @name.setter - @property_not_empty - def name(self, value): - self._name = value - - @property - def description(self): - return self._description - - @description.setter - def description(self, value): - self._description = value - - @property - def remote_type(self): - return self._remote_type - - def _set_values(self, id, name, description, remote_type): - if id is not None: - self._id = id - if name: - self._name = name - if description: - self.description = description - if remote_type: - self._remote_type = remote_type - - @classmethod - def from_response(cls, resp, ns): - all_column_items = list() - parsed_response = fromstring(resp) - all_column_xml = parsed_response.findall(".//t:column", namespaces=ns) - - for column_xml in all_column_xml: - id, name, description, remote_type = cls._parse_element(column_xml, ns) - column_item = cls(name) - column_item._set_values(id, name, description, remote_type) - all_column_items.append(column_item) - - return all_column_items - - @staticmethod - def _parse_element(column_xml, ns): - id = column_xml.get("id", None) - name = column_xml.get("name", None) - description = column_xml.get("description", None) - remote_type = column_xml.get("remoteType", None) - - return id, name, description, remote_type +from defusedxml.ElementTree import fromstring + +from .property_decorators import property_not_empty + + +class ColumnItem: + def __init__(self, name, description=None): + self._id = None + self.description = description + self.name = name + + def __repr__(self): + return f"<{self.__class__.__name__} {self._id} {self.name} {self.description}>" + + @property + def id(self): + return self._id + + @property + def name(self): + return self._name + + @name.setter + @property_not_empty + def name(self, value): + self._name = value + + @property + def description(self): + return self._description + + @description.setter + def description(self, value): + self._description = value + + @property + def remote_type(self): + return self._remote_type + + def _set_values(self, id, name, description, remote_type): + if id is not None: + self._id = id + if name: + self._name = name + if description: + self.description = description + if remote_type: + self._remote_type = remote_type + + @classmethod + def from_response(cls, resp, ns): + all_column_items = list() + parsed_response = fromstring(resp) + all_column_xml = parsed_response.findall(".//t:column", namespaces=ns) + + for column_xml in all_column_xml: + id, name, description, remote_type = cls._parse_element(column_xml, ns) + column_item = cls(name) + column_item._set_values(id, name, description, remote_type) + all_column_items.append(column_item) + + return all_column_items + + @staticmethod + def _parse_element(column_xml, ns): + id = column_xml.get("id", None) + name = column_xml.get("name", None) + description = column_xml.get("description", None) + remote_type = column_xml.get("remoteType", None) + + return id, name, description, remote_type diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index f684c22d4..ae6150eb5 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -49,7 +49,19 @@ class JobItem: The finish code of the job. 0 for success, 1 for failure, 2 for cancelled. notes : list[str] | None - Contains detailed notes about the job. + Detail notes emitted by legacy job types (e.g. extract refresh) inside + job-specific elements like `...`. + For modern job types see `status_notes`. + + status_notes : list[dict] | None + Structured per-row / per-metric status entries from the modern job response + schema (``). + Each element is a dict with keys `type`, `value`, `text` (any of which may + be None if the server omitted them). Populated for UserImport and other + multi-row jobs where individual rows have distinct outcomes; documented + types include `CountOfUsersAddedToSite`, `CountOfUsersSkipped`, + `CountOfUsersWithInsufficientLicenses`, etc. + See https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_job mode : str | None @@ -100,6 +112,7 @@ def __init__( updated_at: datetime.datetime | None = None, workbook_name: str | None = None, datasource_name: str | None = None, + status_notes: list[dict] | None = None, ): self._id = id_ self._type = job_type @@ -116,6 +129,7 @@ def __init__( self._updated_at = updated_at self._workbook_name = workbook_name self._datasource_name = datasource_name + self._status_notes: list[dict] = status_notes or [] @property def id(self) -> str: @@ -149,6 +163,10 @@ def finish_code(self) -> int: def notes(self) -> list[str]: return self._notes + @property + def status_notes(self) -> list[dict]: + return self._status_notes + @property def mode(self) -> str | None: return self._mode @@ -222,6 +240,14 @@ def _parse_element(cls, element, ns): completed_at = parse_datetime(element.get("completedAt", None)) finish_code = int(element.get("finishCode", -1)) notes = [note.text for note in element.findall(".//t:notes", namespaces=ns)] or None + status_notes = [ + { + "type": note.get("type"), + "value": note.get("value"), + "text": note.get("text"), + } + for note in element.findall(".//t:statusNotes/t:statusNote", namespaces=ns) + ] or None mode = element.get("mode", None) workbook = element.find(".//t:workbook[@id]", namespaces=ns) workbook_id = workbook.get("id") if workbook is not None else None @@ -253,6 +279,7 @@ def _parse_element(cls, element, ns): updated_at, workbook_name, datasource_name, + status_notes, ) diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index f53a1e0cf..17fcc3dff 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -1,140 +1,279 @@ -from typing import TYPE_CHECKING - -from defusedxml.ElementTree import fromstring - -from .property_decorators import property_is_boolean -from .target import Target -from tableauserverclient.models import ScheduleItem - -if TYPE_CHECKING: - from .target import Target - - -class SubscriptionItem: - def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None: - self._id = None - self.attach_image = True - self.attach_pdf = False - self.message = None - self.page_orientation = None - self.page_size_option = None - self.schedule_id = schedule_id - self.send_if_view_empty = True - self.subject = subject - self.suspended = False - self.target = target - self.user_id = user_id - self.schedule = None - - def __repr__(self) -> str: - if self.id is not None: - return " bool: - return self._attach_image - - @attach_image.setter - @property_is_boolean - def attach_image(self, value: bool): - self._attach_image = value - - @property - def attach_pdf(self) -> bool: - return self._attach_pdf - - @attach_pdf.setter - @property_is_boolean - def attach_pdf(self, value: bool) -> None: - self._attach_pdf = value - - @property - def send_if_view_empty(self) -> bool: - return self._send_if_view_empty - - @send_if_view_empty.setter - @property_is_boolean - def send_if_view_empty(self, value: bool) -> None: - self._send_if_view_empty = value - - @property - def suspended(self) -> bool: - return self._suspended - - @suspended.setter - @property_is_boolean - def suspended(self, value: bool) -> None: - self._suspended = value - - @classmethod - def from_response(cls: type, xml: bytes, ns) -> list["SubscriptionItem"]: - parsed_response = fromstring(xml) - all_subscriptions_xml = parsed_response.findall(".//t:subscription", namespaces=ns) - - all_subscriptions = [SubscriptionItem._parse_element(x, ns) for x in all_subscriptions_xml] - return all_subscriptions - - @classmethod - def _parse_element(cls, element, ns): - schedule_element = element.find(".//t:schedule", namespaces=ns) - content_element = element.find(".//t:content", namespaces=ns) - user_element = element.find(".//t:user", namespaces=ns) - - # Schedule element - schedule_id = None - schedule = None - if schedule_element is not None: - schedule_id = schedule_element.get("id", None) - - # If schedule id is not provided, then TOL with full schedule provided - if schedule_id is None: - schedule = ScheduleItem.from_element(element, ns) - - # Content element - target = None - send_if_view_empty = None - if content_element is not None: - target = Target(content_element.get("id", None), content_element.get("type")) - send_if_view_empty = string_to_bool(content_element.get("sendIfViewEmpty", "")) - - # User element - user_id = None - if user_element is not None: - user_id = user_element.get("id", None) - - # Main attributes - id_ = element.get("id", None) - subject = element.get("subject", None) - attach_image = string_to_bool(element.get("attachImage", "")) - attach_pdf = string_to_bool(element.get("attachPdf", "")) - message = element.get("message", None) - page_orientation = element.get("pageOrientation", None) - page_size_option = element.get("pageSizeOption", None) - suspended = string_to_bool(element.get("suspended", "")) - - # Create SubscriptionItem and set fields - sub = cls(subject, schedule_id, user_id, target) - sub._id = id_ - sub.attach_image = attach_image - sub.attach_pdf = attach_pdf - sub.message = message - sub.page_orientation = page_orientation - sub.page_size_option = page_size_option - sub.send_if_view_empty = send_if_view_empty - sub.suspended = suspended - sub.schedule = schedule - - return sub - - -# Used to convert string represented boolean to a boolean type -def string_to_bool(s: str) -> bool: - return s.lower() == "true" +from typing import TYPE_CHECKING + +from defusedxml.ElementTree import fromstring + +from .property_decorators import property_is_boolean +from .target import Target +from tableauserverclient.models import ScheduleItem + +if TYPE_CHECKING: + from .target import Target + + +class SubscriptionItem: + """A subscription that sends a view or workbook to a user on a schedule. + + Subscriptions fire on one of two triggers: + + 1. **Time-based** (the common case): the referenced schedule's time + trigger runs -- e.g. a "Weekly Monday 8am" schedule fires the + subscription every Monday at 8am. Construct these with the normal + ``SubscriptionItem(subject, schedule_id, user_id, target)`` form. + + 2. **Extract-refresh-triggered**: the referenced schedule's extract + refresh completes -- the subscription fires alongside the refresh, + so recipients always get the freshest data. Use the + :meth:`on_extract_refresh` classmethod to construct these; it sets + :attr:`refresh_extract_triggered` to ``True`` for you. + + In the Cloud web UI, extract-refresh-triggered subscriptions show up + as schedule "On Extract Refresh". At the REST API level there is no + "On Extract Refresh" schedule type; instead the subscription + references an existing extract-refresh schedule *and* sets + ``refreshExtractTriggered=true`` on the payload. + + Examples + -------- + Time-based subscription: + + >>> sub = TSC.SubscriptionItem( + ... subject="Weekly report", + ... schedule_id=weekly_schedule.id, + ... user_id=user.id, + ... target=TSC.Target(view.id, "view"), + ... ) + >>> server.subscriptions.create(sub) + + Extract-refresh-triggered subscription: + + >>> sub = TSC.SubscriptionItem.on_extract_refresh( + ... subject="Send when refresh finishes", + ... extract_refresh_schedule_id=nightly_refresh_schedule.id, + ... user_id=user.id, + ... target=TSC.Target(view.id, "view"), + ... ) + >>> server.subscriptions.create(sub) + """ + + def __init__(self, subject: str, schedule_id: str | None, user_id: str, target: "Target") -> None: + self._id = None + self.attach_image = True + self.attach_pdf = False + self.message = None + self.page_orientation = None + self.page_size_option = None + self.schedule_id = schedule_id + self.send_if_view_empty = True + self.subject = subject + self.suspended = False + self.target = target + self.user_id = user_id + self.schedule = None + self._refresh_extract_triggered: bool = False + + @classmethod + def on_extract_refresh( + cls, + subject: str, + extract_refresh_schedule_id: str, + user_id: str, + target: "Target", + ) -> "SubscriptionItem": + """Construct a subscription that fires when an extract refresh runs. + + The subscription references an existing extract-refresh schedule and + will fire alongside that schedule's extract refresh, so recipients + get the freshest data. Server-side this maps to + ``refreshExtractTriggered=true`` on the subscription entity; the Cloud + UI surfaces the same state as schedule type "On Extract Refresh". + + Parameters + ---------- + subject : str + Subscription subject line, shown in the delivered email. + extract_refresh_schedule_id : str + ID of an existing schedule that owns an extract refresh. On Cloud + list schedules with ``server.schedules.get()`` and filter to the + extract-refresh schedules; on-prem the same list is populated by + the site's server-authored schedules. + user_id : str + ID of the recipient user. + target : Target + The workbook or view to send. + + Returns + ------- + SubscriptionItem + A subscription with ``refresh_extract_triggered`` set to True. + Pass to ``server.subscriptions.create(...)`` to create it. + + Notes + ----- + This factory does not validate that ``extract_refresh_schedule_id`` + actually references an extract-refresh schedule. Referencing a + non-extract schedule with ``refresh_extract_triggered=True`` is a + server-side error and will surface when ``create()`` is called. + + Related to tableau/server-client-python#1658. + """ + sub = cls(subject, extract_refresh_schedule_id, user_id, target) + sub.refresh_extract_triggered = True + return sub + + def __repr__(self) -> str: + if self.id is not None: + return " bool: + return self._attach_image + + @attach_image.setter + @property_is_boolean + def attach_image(self, value: bool): + self._attach_image = value + + @property + def attach_pdf(self) -> bool: + return self._attach_pdf + + @attach_pdf.setter + @property_is_boolean + def attach_pdf(self, value: bool) -> None: + self._attach_pdf = value + + @property + def send_if_view_empty(self) -> bool: + return self._send_if_view_empty + + @send_if_view_empty.setter + @property_is_boolean + def send_if_view_empty(self, value: bool) -> None: + self._send_if_view_empty = value + + @property + def suspended(self) -> bool: + return self._suspended + + @suspended.setter + @property_is_boolean + def suspended(self, value: bool) -> None: + self._suspended = value + + @property + def refresh_extract_triggered(self) -> bool: + """Whether this subscription fires when its schedule's extract refresh runs. + + When True, the subscription must reference an existing extract-refresh + schedule (via ``schedule_id``) and will fire alongside that schedule's + extract refresh. When False (the default), the subscription fires on + the schedule's time trigger like every other subscription. + + The Cloud web UI surfaces the True state as schedule type "On Extract + Refresh"; there is no such REST-API schedule type, so callers must set + this flag explicitly. Prefer :meth:`on_extract_refresh` when + constructing new extract-refresh-triggered subscriptions -- it wires + up ``schedule_id`` and this flag together in one call. + + Setting this to True on a subscription that references a non-extract + schedule (Subscription, Flow, System, etc.) is a server-side error; + the ``create()``/``update()`` call will raise. TSC does not fetch the + referenced schedule to validate this client-side. + + **Updating an existing subscription:** if an update changes the + referenced schedule, the server silently forces this flag back to + False on that same call, regardless of what the client sent. To + convert a time-based subscription into an extract-refresh-triggered + one, issue two updates: first change ``schedule_id``, then set + ``refresh_extract_triggered = True`` on a second call. + + **Manual-build update() footgun:** every ``subscriptions.update()`` + payload now carries ``refreshExtractTriggered="true"`` or + ``"false"``. The safe pattern is fetch-then-mutate-then-update, so + the value round-trips through the parser. If instead you build a + fresh ``SubscriptionItem`` locally, assign ``_id`` yourself, and + call ``update()``, the default False on the new item will flip an + existing extract-refresh-triggered subscription off on the server. + Fetch first. + """ + return self._refresh_extract_triggered + + @refresh_extract_triggered.setter + @property_is_boolean + def refresh_extract_triggered(self, value: bool) -> None: + self._refresh_extract_triggered = value + + @classmethod + def from_response(cls: type, xml: bytes, ns) -> list["SubscriptionItem"]: + parsed_response = fromstring(xml) + all_subscriptions_xml = parsed_response.findall(".//t:subscription", namespaces=ns) + + all_subscriptions = [SubscriptionItem._parse_element(x, ns) for x in all_subscriptions_xml] + return all_subscriptions + + @classmethod + def _parse_element(cls, element, ns): + schedule_element = element.find(".//t:schedule", namespaces=ns) + content_element = element.find(".//t:content", namespaces=ns) + user_element = element.find(".//t:user", namespaces=ns) + + # Schedule element + schedule_id = None + schedule = None + if schedule_element is not None: + schedule_id = schedule_element.get("id", None) + + # If schedule id is not provided, then TOL with full schedule provided + if schedule_id is None: + schedule = ScheduleItem.from_element(element, ns) + + # Content element + target = None + send_if_view_empty = None + if content_element is not None: + target = Target(content_element.get("id", None), content_element.get("type")) + send_if_view_empty = string_to_bool(content_element.get("sendIfViewEmpty", "")) + + # User element + user_id = None + if user_element is not None: + user_id = user_element.get("id", None) + + # Main attributes + id_ = element.get("id", None) + subject = element.get("subject", None) + attach_image = string_to_bool(element.get("attachImage", "")) + attach_pdf = string_to_bool(element.get("attachPdf", "")) + message = element.get("message", None) + page_orientation = element.get("pageOrientation", None) + page_size_option = element.get("pageSizeOption", None) + suspended = string_to_bool(element.get("suspended", "")) + refresh_extract_triggered = string_to_bool(element.get("refreshExtractTriggered", "")) + + # Create SubscriptionItem and set fields + sub = cls(subject, schedule_id, user_id, target) + sub._id = id_ + sub.attach_image = attach_image + sub.attach_pdf = attach_pdf + sub.message = message + sub.page_orientation = page_orientation + sub.page_size_option = page_size_option + sub.send_if_view_empty = send_if_view_empty + sub.suspended = suspended + sub.schedule = schedule + sub.refresh_extract_triggered = refresh_extract_triggered + + return sub + + +# Used to convert string represented boolean to a boolean type +def string_to_bool(s: str) -> bool: + return s.lower() == "true" diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index d69424e44..8bfd71b6f 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -43,6 +43,14 @@ def create(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item: error = "No Susbcription provided" raise ValueError(error) + if not subscription_item.schedule_id: + # See tableau/server-client-python#1658: users trying to create an + # "On Extract Refresh" subscription pass schedule_id=None and hit + # a confusing wire-layer error. Point them at the factory. + raise ValueError( + "schedule_id is required; for on-extract-refresh subscriptions " + "use SubscriptionItem.on_extract_refresh(...)" + ) logger.info(f"Creating a subscription ({subscription_item})") url = self.baseurl create_req = RequestFactory.Subscription.create_req(subscription_item) @@ -63,6 +71,12 @@ def update(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item.id: error = "Subscription item missing ID. Subscription must be retrieved from server first." raise MissingRequiredFieldError(error) + if not subscription_item.schedule_id: + # A subscription round-tripped from an inline-schedule response + # (Cloud/TOL) has schedule_id=None. Updating it in that state + # sends with no id and hits the same wire-layer error + # that create() guards against. See tableau/server-client-python#1658. + raise ValueError("schedule_id is required to update a subscription") url = f"{self.baseurl}/{subscription_item.id}" update_req = RequestFactory.Subscription.update_req(subscription_item) server_response = self.put_request(url, update_req) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index fc4694c01..91acbd218 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1334,6 +1334,13 @@ def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt subscription_element.attrib["pageOrientation"] = subscription_item.page_orientation if subscription_item.page_size_option is not None: subscription_element.attrib["pageSizeOption"] = subscription_item.page_size_option + # On create, only emit refreshExtractTriggered when True -- server default + # is False, and emitting the attribute unconditionally would surface as a + # payload change on servers that treat absence differently from an explicit + # False. update_req is asymmetric here: it must emit False to enable the + # True -> False transition on an existing subscription. + if subscription_item.refresh_extract_triggered: + subscription_element.attrib["refreshExtractTriggered"] = "true" # Content element content_element = ET.SubElement(subscription_element, "content") @@ -1342,7 +1349,10 @@ def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt if subscription_item.send_if_view_empty is not None: content_element.attrib["sendIfViewEmpty"] = str(subscription_item.send_if_view_empty).lower() - # Schedule element + # Schedule element. schedule_id can be None on items parsed from + # inline-schedule responses; subscriptions.create() guards against + # that before we get here, so the value is non-None at this point. + assert subscription_item.schedule_id is not None schedule_element = ET.SubElement(subscription_element, "schedule") schedule_element.attrib["id"] = subscription_item.schedule_id @@ -1368,6 +1378,10 @@ def update_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt subscription.attrib["pageSizeOption"] = subscription_item.page_size_option if subscription_item.suspended is not None: subscription.attrib["suspended"] = str(subscription_item.suspended).lower() + # update_req always emits the flag so callers can turn it off. The + # server retains the prior value when the attribute is absent, so + # omission would silently prevent True -> False transitions. + subscription.attrib["refreshExtractTriggered"] = str(subscription_item.refresh_extract_triggered).lower() # Schedule element schedule = ET.SubElement(subscription, "schedule") diff --git a/test/test_job.py b/test/test_job.py index 19f324d1e..7bfdd1840 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -63,6 +63,53 @@ def test_get_by_id(server: TSC.Server) -> None: assert job_id == job.id assert updated_at == job.updated_at assert job.notes == ["Job detail notes"] + # Regression for #1850: the response also carries a + # block per the public REST doc. Verify it now surfaces via job.status_notes. + assert job.status_notes == [ + { + "type": "CountOfUsersAddedToGroup", + "value": "5", + "text": "Description of how many users were added to the group during the import.", + } + ] + + +def test_status_notes_empty_when_absent() -> None: + # A job element with no yields an empty list, not None or an error. + xml = ( + b"" + b"" + b"" + ) + jobs = TSC.JobItem.from_response(xml, {"t": "http://tableau.com/api"}) + assert len(jobs) == 1 + assert jobs[0].status_notes == [] + + +def test_status_notes_multiple_entries() -> None: + # Multiple statusNote elements yield an ordered list of dicts. Any of type / + # value / text may be absent on a given note; missing attributes come back as None. + xml = ( + b"" + b"" + b"" + b"" + b"" + b"" + b"" + b"" + b"" + b"" + ) + jobs = TSC.JobItem.from_response(xml, {"t": "http://tableau.com/api"}) + assert len(jobs) == 1 + notes = jobs[0].status_notes + assert notes == [ + {"type": "line", "value": "0", "text": None}, + {"type": "errorCode", "value": "1", "text": None}, + {"type": "message", "value": "Actor does not have permission", "text": None}, + {"type": "username", "value": "unknown", "text": None}, + ] def test_get_before_signin(server: TSC.Server) -> None: diff --git a/test/test_subscription.py b/test/test_subscription.py index 7c78cc57d..991d67b84 100644 --- a/test/test_subscription.py +++ b/test/test_subscription.py @@ -100,3 +100,196 @@ def test_delete_subscription(server: TSC.Server) -> None: with requests_mock.mock() as m: m.delete(server.subscriptions.baseurl + "/78e9318d-2d29-4d67-b60f-3f2f5fd89ecc", status_code=204) server.subscriptions.delete("78e9318d-2d29-4d67-b60f-3f2f5fd89ecc") + + +# ----------------------------------------------------------------- +# refresh_extract_triggered (aka "On Extract Refresh" subscriptions) +# ----------------------------------------------------------------- + + +def test_create_rejects_none_schedule_id(server: TSC.Server) -> None: + """Regression for tableau/server-client-python#1658: users trying to create + an 'On Extract Refresh' subscription would pass schedule_id=None. Point them + at on_extract_refresh() instead of letting the failure surface deep in + the wire layer as a ServerResponseError. The check lives in create() (not + __init__) so parse can still build items from server responses that use + the inline-schedule form (no schedule id on the wire). + """ + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem("subject", None, "user-id", target) + with pytest.raises(ValueError, match="on_extract_refresh"): + server.subscriptions.create(sub) + + +def test_create_rejects_empty_schedule_id(server: TSC.Server) -> None: + """Same failure mode as None: an empty-string schedule_id would serialize + as and hit a confusing server error. + """ + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem("subject", "", "user-id", target) + with pytest.raises(ValueError, match="on_extract_refresh"): + server.subscriptions.create(sub) + + +def test_subscription_defaults_refresh_extract_triggered_false(server: TSC.Server) -> None: + """A default SubscriptionItem does not opt into extract-refresh triggering.""" + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem("subject", "sched-id", "user-id", target) + assert sub.refresh_extract_triggered is False + + +def test_on_extract_refresh_factory_sets_flag(server: TSC.Server) -> None: + """The on_extract_refresh factory produces a subscription with the flag set + and the extract-refresh schedule id in place -- server rejects a payload + that has the flag without a schedule reference, so both must be set together. + """ + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem.on_extract_refresh( + subject="On refresh", + extract_refresh_schedule_id="refresh-sched-id", + user_id="user-id", + target=target, + ) + assert sub.refresh_extract_triggered is True + assert sub.schedule_id == "refresh-sched-id" + assert sub.subject == "On refresh" + assert sub.user_id == "user-id" + assert sub.target is target + + +def test_create_req_emits_refresh_extract_triggered_when_set(server: TSC.Server) -> None: + """When the flag is set, the outbound XML should carry + refreshExtractTriggered='true' on the element. + """ + from tableauserverclient.server.request_factory import RequestFactory + + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem.on_extract_refresh( + subject="On refresh", + extract_refresh_schedule_id="refresh-sched-id", + user_id="user-id", + target=target, + ) + body = RequestFactory.Subscription.create_req(sub).decode("utf-8") + assert 'refreshExtractTriggered="true"' in body + + +def test_create_req_omits_refresh_extract_triggered_when_false(server: TSC.Server) -> None: + """A default subscription must not emit refreshExtractTriggered=false. Some + servers treat absence and False differently; we send only when the caller + has explicitly opted in. + """ + from tableauserverclient.server.request_factory import RequestFactory + + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem("subject", "sched-id", "user-id", target) + body = RequestFactory.Subscription.create_req(sub).decode("utf-8") + assert "refreshExtractTriggered" not in body + + +def test_parse_response_reads_refresh_extract_triggered(server: TSC.Server) -> None: + """A subscription XML element carrying refreshExtractTriggered='true' + parses back into refresh_extract_triggered=True on the SubscriptionItem. + """ + xml = ( + b'' + b" " + b' ' + b' ' + b' ' + b' ' + b" " + b" " + b"" + ) + subs = TSC.SubscriptionItem.from_response(xml, {"t": "http://tableau.com/api"}) + assert len(subs) == 1 + assert subs[0].refresh_extract_triggered is True + assert subs[0].schedule_id == "refresh-sched-1" + + +def test_update_rejects_missing_schedule_id(server: TSC.Server) -> None: + """A subscription round-tripped from an inline-schedule response has + schedule_id=None. Calling update() on it would send with no id + and hit a confusing wire-layer error. Catch it at the endpoint instead. + """ + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem("subject", "sched-id", "user-id", target) + sub._id = "existing-sub-id" # type: ignore[assignment] + sub.schedule_id = None + with pytest.raises(ValueError, match="schedule_id is required"): + server.subscriptions.update(sub) + + +def test_update_req_emits_refresh_extract_triggered_when_true(server: TSC.Server) -> None: + """When the flag is True, update_req must emit refreshExtractTriggered='true'.""" + from tableauserverclient.server.request_factory import RequestFactory + + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem.on_extract_refresh( + subject="On refresh", + extract_refresh_schedule_id="refresh-sched-id", + user_id="user-id", + target=target, + ) + body = RequestFactory.Subscription.update_req(sub).decode("utf-8") + assert 'refreshExtractTriggered="true"' in body + + +def test_update_req_emits_refresh_extract_triggered_when_false(server: TSC.Server) -> None: + """update_req must emit refreshExtractTriggered='false' so callers can turn + the flag off. The server retains the prior value when the attribute is + absent, so omission would silently prevent True -> False transitions. + """ + from tableauserverclient.server.request_factory import RequestFactory + + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem("subject", "sched-id", "user-id", target) + assert sub.refresh_extract_triggered is False + body = RequestFactory.Subscription.update_req(sub).decode("utf-8") + assert 'refreshExtractTriggered="false"' in body + + +def test_parse_response_with_inline_schedule_no_id(server: TSC.Server) -> None: + """Regression: on Cloud/TOL the server may return a element with + no id attribute (the full schedule is inlined instead). Parse must handle + this without raising -- the constructor cannot demand a schedule_id here. + """ + xml = ( + b'' + b" " + b' ' + b' ' + b' ' + b' ' + b" " + b' ' + b" " + b" " + b"" + ) + subs = TSC.SubscriptionItem.from_response(xml, {"t": "http://tableau.com/api"}) + assert len(subs) == 1 + assert subs[0].schedule_id is None + assert subs[0].schedule is not None + + +def test_parse_response_missing_refresh_extract_triggered_defaults_false(server: TSC.Server) -> None: + """Backward compatibility: a subscription XML element without the attribute + parses back to refresh_extract_triggered=False. + """ + xml = ( + b'' + b" " + b' ' + b' ' + b' ' + b' ' + b" " + b" " + b"" + ) + subs = TSC.SubscriptionItem.from_response(xml, {"t": "http://tableau.com/api"}) + assert len(subs) == 1 + assert subs[0].refresh_extract_triggered is False