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