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/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 000000000..5d042a9b8
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,33 @@
+
+
+## Motivation
+
+
+
+## Behavior change
+
+
+
+## Test plan
+
+
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 000000000..1e2ae4b6a
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,18 @@
+version: 2
+updates:
+ - package-ecosystem: "pip"
+ directory: "/"
+ target-branch: "development"
+ # Security advisories fire independently of the weekly schedule as long
+ # as the repo has "Dependabot security updates" enabled under
+ # Settings -> Code security. Weekly = non-security updates.
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 10
+
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ target-branch: "development"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 10
diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml
index 70bc845e9..9f5e53acb 100644
--- a/.github/workflows/code-coverage.yml
+++ b/.github/workflows/code-coverage.yml
@@ -16,10 +16,10 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }}
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
diff --git a/.github/workflows/link-issues-to-prs.yml b/.github/workflows/link-issues-to-prs.yml
new file mode 100644
index 000000000..1ab69132f
--- /dev/null
+++ b/.github/workflows/link-issues-to-prs.yml
@@ -0,0 +1,104 @@
+name: Mark linked issues in-progress
+
+# When an open PR references an issue with Closes/Fixes/Resolves, apply the
+# 'in-progress' label to that issue so the stale bot leaves it alone (the
+# stale workflow already exempts 'in-progress'). Remove the label when the
+# PR closes without merging, so a genuinely abandoned PR does not keep its
+# referenced issues shielded forever.
+#
+# actions/stale looks at issue-level events only; a PR that references an
+# issue does not reset the issue's stale timer or move it off the stale
+# label. This workflow bridges that gap.
+#
+# Security note: the script only reads pr.body, extracts decimal issue
+# numbers via a fixed regex, and passes those numbers to the REST API.
+# Body content is never expanded into a run: command or a shell.
+
+on:
+ pull_request_target:
+ types: [opened, edited, reopened, synchronize, ready_for_review, closed]
+
+permissions:
+ issues: write
+ pull-requests: read
+
+jobs:
+ link:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/github-script@v9
+ with:
+ script: |
+ const pr = context.payload.pull_request;
+ const body = pr.body || '';
+ const re = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi;
+ // Cross-repo references (owner/repo#123) are intentionally skipped;
+ // this workflow only labels issues in the current repo.
+ const MAX_REFS = 50; // cap runaway PR bodies from forks (pull_request_target).
+ const extract = (text) => [...new Set(
+ [...(text || '').matchAll(re)].map(m => Number(m[1]))
+ )].slice(0, MAX_REFS);
+
+ const current = extract(body);
+
+ // On `edited`, compute which references were REMOVED so their
+ // labels come off. Without this, a PR that once said "Closes #42"
+ // and no longer does would leave #42 shielded indefinitely.
+ let removed = [];
+ if (context.payload.action === 'edited') {
+ const prevBody = context.payload.changes?.body?.from;
+ if (prevBody !== undefined) {
+ const previous = extract(prevBody);
+ const now = new Set(current);
+ removed = previous.filter(n => !now.has(n));
+ }
+ }
+
+ // Apply the label while the PR is open. Remove it when the PR
+ // closes without merging (merged PRs also close, but the issue
+ // will be auto-closed by GitHub once the merge lands, so the
+ // in-progress label on it is harmless). Also remove on `edited`
+ // when a reference was deleted from the body.
+ const shouldLabel = pr.state === 'open';
+ const closeUnlabel = pr.state === 'closed' && !pr.merged ? current : [];
+ const toUnlabel = [...new Set([...removed, ...closeUnlabel])];
+
+ if (current.length === 0 && toUnlabel.length === 0) {
+ core.info('No Closes/Fixes/Resolves references to process; nothing to do.');
+ return;
+ }
+
+ const removeLabelSafe = async (n) => {
+ await github.rest.issues.removeLabel({
+ ...context.repo,
+ issue_number: n,
+ name: 'in-progress',
+ }).catch(err => {
+ // 404 just means the label was not present; not an error.
+ if (err.status !== 404) throw err;
+ });
+ core.info(`#${n}: removed in-progress`);
+ };
+
+ if (shouldLabel) {
+ for (const n of current) {
+ try {
+ await github.rest.issues.addLabels({
+ ...context.repo,
+ issue_number: n,
+ labels: ['in-progress'],
+ });
+ core.info(`#${n}: added in-progress`);
+ } catch (err) {
+ core.warning(`#${n}: ${err.message}`);
+ }
+ }
+ }
+
+ for (const n of toUnlabel) {
+ try {
+ await removeLabelSafe(n);
+ } catch (err) {
+ core.warning(`#${n}: ${err.message}`);
+ }
+ }
diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml
index 0e2b425ee..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 }}
@@ -20,17 +20,17 @@ jobs:
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
- name: cache
- uses: actions/cache@v4
+ uses: actions/cache@v6
with:
path: ${{ steps.pip-cache.outputs.dir }}
key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }}
restore-keys: |
${{ runner.os }}-${{ matrix.python-version }}-pip-
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }}
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml
index fe3728b9d..8bc11466d 100644
--- a/.github/workflows/publish-pypi.yml
+++ b/.github/workflows/publish-pypi.yml
@@ -15,10 +15,10 @@ jobs:
name: Build and check wheel
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
with:
fetch-depth: 0
- - uses: actions/setup-python@v5
+ - uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Build dist files
@@ -29,7 +29,7 @@ jobs:
git describe --tag --dirty --always
- name: Check wheel structure
run: python -m pytest test/test_packaging.py -m packaging -v
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@v7
with:
name: dist
path: dist/
@@ -39,7 +39,7 @@ jobs:
needs: build
runs-on: ubuntu-latest
steps:
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@v8
with:
name: dist
path: dist/
@@ -60,7 +60,7 @@ jobs:
python-version: ['3.x']
runs-on: ${{ matrix.os }}
steps:
- - uses: actions/setup-python@v5
+ - uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
- name: Install from Test PyPI
@@ -75,8 +75,9 @@ jobs:
needs: smoke-test-pypi
if: ${{ github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') }}
runs-on: ubuntu-latest
+ environment: pypi
steps:
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@v8
with:
name: dist
path: dist/
diff --git a/.github/workflows/pypi-smoke-tests.yml b/.github/workflows/pypi-smoke-tests.yml
index a125b4bc5..930d1bd5b 100644
--- a/.github/workflows/pypi-smoke-tests.yml
+++ b/.github/workflows/pypi-smoke-tests.yml
@@ -25,7 +25,7 @@ jobs:
steps:
- name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }}
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
- name: pip install
diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
index 9ac7ebb6a..aab5d1cb3 100644
--- a/.github/workflows/run-tests.yml
+++ b/.github/workflows/run-tests.yml
@@ -25,17 +25,17 @@ jobs:
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
- name: cache
- uses: actions/cache@v4
+ uses: actions/cache@v6
with:
path: ${{ steps.pip-cache.outputs.dir }}
key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }}
restore-keys: |
${{ runner.os }}-${{ matrix.python-version }}-pip-
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }}
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
allow-prereleases: ${{ matrix.allow-prereleases || false }}
diff --git a/.github/workflows/slack.yml b/.github/workflows/slack.yml
index 9afebf25b..65cfc3cde 100644
--- a/.github/workflows/slack.yml
+++ b/.github/workflows/slack.yml
@@ -10,7 +10,7 @@ jobs:
steps:
- name: Send message to Slack API
continue-on-error: true
- uses: archive/github-actions-slack@v2.8.0
+ uses: archive/github-actions-slack@v3.1.0
id: notify
with:
slack-bot-user-oauth-access-token: ${{ secrets.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN }}
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
new file mode 100644
index 000000000..09d376a21
--- /dev/null
+++ b/.github/workflows/stale.yml
@@ -0,0 +1,44 @@
+name: Stale issues and pull requests
+
+on:
+ schedule:
+ - cron: '30 1 * * *'
+ workflow_dispatch:
+
+permissions:
+ issues: write
+ pull-requests: write
+ actions: write
+
+jobs:
+ stale:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/stale@v11
+ with:
+ stale-issue-label: 'stale'
+ stale-pr-label: 'stale'
+ exempt-issue-labels: 'good first issue,Design Proposal,in-progress,Server-Side Enhancement'
+ exempt-pr-labels: 'in-progress'
+
+ days-before-issue-stale: 60
+ days-before-issue-close: 14
+ days-before-pr-stale: 90
+ days-before-pr-close: 14
+
+ operations-per-run: 300
+
+ close-issue-reason: 'not_planned'
+
+ stale-issue-message: >
+ This issue has been automatically marked as stale because it has not had
+ recent activity. It will be closed in 14 days if no further activity occurs.
+ If this is still relevant, please comment to keep it open.
+ stale-pr-message: >
+ This pull request has been automatically marked as stale because it has not had
+ recent activity. It will be closed in 14 days if no further activity occurs.
+ If this is still active work, please comment or push a new commit to keep it open.
+ close-issue-message: >
+ Closing due to inactivity. If this is still relevant, please reopen or file a new issue.
+ close-pr-message: >
+ Closing due to inactivity. Feel free to reopen if you'd like to continue this work.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c018294d3..db9780ee9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,50 @@
+## Unreleased
+
+* Bumped the urllib3 floor to 2.6.3 to pick up the fix for CVE-2026-21441
+ (GHSA-38jv-5279-wg99, 8.9 High): urllib3's streaming decompression
+ safeguards were bypassed when HTTP redirects were followed. TSC's manual
+ redirect walker (#1848) disables urllib3's built-in follower on new code
+ paths, but downstream callers using urllib3 directly (and TSC endpoints
+ that predate #1848) still relied on the built-in path, so the floor bump
+ closes the gap for all callers. The existing `<3` upper bound is unchanged.
+* 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.
+* Preserve HTTP method and body across 3xx redirects. Previously `requests`
+ followed 301/302/303 by converting POST to GET and dropping the body, so
+ endpoints like `users.add`, `workbooks.publish`, and any write hitting a
+ server behind a redirect would 405. TSC now disables `requests`'s
+ auto-redirect and walks the chain manually, up to `session.max_redirects`
+ hops (default 30). Refuses HTTPS -> HTTP scheme downgrades and raises
+ `RedirectError` with a clear message on missing `Location` headers or hop
+ overflow. Fixes #1127 and #1828.
+* `UserItem.CSVImport.create_user_from_line` no longer
+ lowercases the entire CSV line before parsing. Previously the whole line,
+ including the username, display name, fullname, and email fields, was
+ lowercased destructively (e.g. `JSmith` became `jsmith`). Case is now
+ preserved for those fields; only the comparison-relevant fields (license,
+ admin_level, publisher, auth_setting) are normalized internally for
+ validation. Callers relying on the previous lowercased output -- e.g. dict
+ lookups keyed on `user.name`, or assertions against lowercased values --
+ need to update. This unblocks CSV imports for LDAP and other case-sensitive
+ auth backends where mixed-case usernames must be preserved.
+* `UserItem.CSVImport._validate_attribute_value` and the too-many-columns
+ branch of `_validate_import_line_or_throw` now raise `ValueError` instead
+ of `AttributeError` for invalid CSV input. `AttributeError` was the wrong
+ exception type for input validation and inconsistent with
+ `create_user_from_line`. `validate_file_for_import` catches `Exception` so
+ it is unaffected; direct callers who caught `AttributeError` specifically
+ need to widen their handler.
+* 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
* added linting and type hints
diff --git a/pyproject.toml b/pyproject.toml
index 8134f73f1..2cce7c61f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,7 +16,7 @@ dependencies = [
'defusedxml>=0.7.1', # latest as at 7/31/23
'packaging>=23.1', # latest as at 7/31/23
'requests>=2.32', # latest as at 7/31/23
- 'urllib3>=2.6.0,<3',
+ 'urllib3>=2.6.3,<3',
'typing_extensions>=4.0',
]
requires-python = ">=3.10"
@@ -33,7 +33,7 @@ classifiers = [
repository = "https://github.com/tableau/server-client-python"
[project.optional-dependencies]
-test = ["black==26.3.1", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests",
+test = ["black==26.10.1", "build", "mypy==2.3.1", "pytest>=7.0", "pytest-cov", "pytest-subtests",
"pytest-xdist", "requests-mock>=1.0,<2.0", "types-requests>=2.32.4.20250913"]
[tool.setuptools.package-data]
@@ -49,7 +49,7 @@ version = {attr = "versioneer.get_version"}
[tool.black]
line-length = 120
-target-version = ['py39', 'py310', 'py311', 'py312', 'py313']
+target-version = ['py310', 'py311', 'py312', 'py313', 'py314']
force-exclude = "tableauserverclient/bin/*"
[tool.mypy]
diff --git a/samples/_shared.py b/samples/_shared.py
new file mode 100644
index 000000000..d24cdddc9
--- /dev/null
+++ b/samples/_shared.py
@@ -0,0 +1,267 @@
+####
+# Shared helpers for the sample scripts in this directory.
+#
+# The most important thing here is `resolve_credentials`, which lets samples
+# accept a Tableau server URL, site, and credentials from three sources:
+#
+# 1. Command-line arguments (useful for CI, but note that these end up in
+# shell history and process listings, so avoid them for real secrets).
+# 2. Environment variables. We look for a `.env` file in the current
+# working directory, in the samples/ directory, and at the repository
+# root, in that order, and load whichever we find first -- only the
+# standard `KEY=value` lines, no external dependency required.
+# 3. Interactive prompts. Missing values are asked for on stdin when
+# stdin is a terminal; secrets are read with `getpass.getpass` so they
+# are not echoed. In non-interactive contexts (CI, piped input) we skip
+# the prompts and let `build_auth` raise instead of hanging on `input()`.
+#
+# CLI args take precedence, then environment, then interactive prompt.
+# This lets a user set defaults in a `.env` file and override individual
+# values on the command line.
+#
+# Sign-in short flags follow the tabcmd convention (-s server, -t site,
+# -u username, -p password). --token-name and --token-value do not have
+# short flags because tabcmd does not either and re-using a letter here
+# would silently accept a token as a password on old command lines.
+####
+
+from __future__ import annotations
+
+import argparse
+import getpass
+import os
+import sys
+from pathlib import Path
+from typing import Iterable
+
+import tableauserverclient as TSC
+
+# Recognized environment variable names, in the order we look them up.
+# Older samples used TABLEAU_SERVER etc; keep those working as aliases.
+_ENV_ALIASES: dict[str, tuple[str, ...]] = {
+ "server": ("TABLEAU_SERVER", "SERVER"),
+ "site": ("TABLEAU_SITE", "SITE"),
+ "token_name": ("TABLEAU_TOKEN_NAME", "TOKEN_NAME"),
+ "token_value": ("TABLEAU_TOKEN_VALUE", "TOKEN_VALUE"),
+ "username": ("TABLEAU_USERNAME", "USERNAME"),
+ "password": ("TABLEAU_PASSWORD", "PASSWORD"),
+ "jwt": ("TABLEAU_JWT", "JWT"),
+ "jwt_file": ("TABLEAU_JWT_FILE", "JWT_FILE"),
+}
+
+
+def add_common_arguments(parser: argparse.ArgumentParser) -> None:
+ """Add the sign-in and logging arguments used by every sample.
+
+ Short flags follow the tabcmd convention: -s server, -t site,
+ -u username, -p password, -l logging-level. --token-name /
+ --token-value and --jwt / --jwt-file intentionally have no short
+ flag; re-using letters here risked silently accepting a token as
+ a password on scripts that pre-date the shared helper. All args
+ are optional; missing values are pulled from the environment or
+ prompted for interactively.
+ """
+ parser.add_argument("--server", "-s", help="server address (env: TABLEAU_SERVER)")
+ parser.add_argument("--site", "-t", help="site content URL (env: TABLEAU_SITE)")
+ parser.add_argument(
+ "--token-name",
+ help="name of the personal access token used to sign into the server " "(env: TABLEAU_TOKEN_NAME)",
+ )
+ parser.add_argument(
+ "--token-value",
+ help="value of the personal access token used to sign into the server "
+ "(env: TABLEAU_TOKEN_VALUE). Prefer the env var or interactive prompt over the "
+ "command line so the secret does not land in shell history.",
+ )
+ parser.add_argument(
+ "--username",
+ "-u",
+ help="username to sign into the server (env: TABLEAU_USERNAME). Only used if "
+ "no personal access token or JWT is supplied.",
+ )
+ parser.add_argument(
+ "--password",
+ "-p",
+ help="password (env: TABLEAU_PASSWORD). Prefer the env var or interactive " "prompt over the command line.",
+ )
+ parser.add_argument(
+ "--jwt",
+ help="encoded JSON Web Token for Connected-App sign-in (env: TABLEAU_JWT). "
+ "When multiple auth options are set, JWT wins over PAT and PAT wins over "
+ "username/password (see build_auth in _shared.py and JWTAuth in the docs).",
+ )
+ parser.add_argument(
+ "--jwt-file",
+ help="path to a file whose contents are the encoded JWT (env: TABLEAU_JWT_FILE). "
+ "Useful for pipelines that mint a JWT into a file rather than an env var.",
+ )
+ parser.add_argument(
+ "--env-file",
+ help="path to a .env-style file with KEY=value lines to load. If omitted, "
+ ".env is looked for in the current directory, the samples/ directory, and "
+ "the repository root, and the first one found is loaded.",
+ )
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ help="desired logging level (set to error by default)",
+ )
+
+
+def _load_env_file(path: Path) -> None:
+ """Very small `.env` loader: `KEY=value` per line, `#` for comments.
+
+ We do not want a runtime dependency on python-dotenv for the samples,
+ so this parses just the common cases. Existing env vars are not
+ overwritten -- a value already in `os.environ` wins.
+ """
+ try:
+ text = path.read_text(encoding="utf-8")
+ except OSError:
+ return
+ for raw_line in text.splitlines():
+ line = raw_line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, _, value = line.partition("=")
+ key = key.strip()
+ value = value.strip().strip("'\"")
+ if key and key not in os.environ:
+ os.environ[key] = value
+
+
+def _first_env(names: Iterable[str]) -> str | None:
+ for name in names:
+ val = os.environ.get(name)
+ if val:
+ return val
+ return None
+
+
+def _candidate_env_paths() -> list[Path]:
+ """Locations we check for a .env file, in priority order.
+
+ cwd first (so the invoker can override), then the directory that holds
+ this shared module (samples/), then the repository root one level up.
+ """
+ module_dir = Path(__file__).resolve().parent
+ return [
+ Path.cwd() / ".env",
+ module_dir / ".env",
+ module_dir.parent / ".env",
+ ]
+
+
+def resolve_credentials(args: argparse.Namespace, *, allow_prompt: bool = True) -> None:
+ """Fill in server/site/credential values on `args` from env or prompt.
+
+ Precedence for each field: existing value on `args` > environment variable
+ > interactive prompt (only when allow_prompt is true AND stdin is a TTY).
+
+ Pass `allow_prompt=False`, or run with stdin redirected (CI, piped input),
+ to skip the prompts entirely; the caller should then verify the fields it
+ needs are set, or let `build_auth` raise a clear ValueError.
+ """
+ # Load `.env` file if one is requested or available.
+ env_file = getattr(args, "env_file", None)
+ if env_file:
+ _load_env_file(Path(env_file))
+ else:
+ for candidate in _candidate_env_paths():
+ if candidate.is_file():
+ _load_env_file(candidate)
+ break
+
+ # For each field, prefer the CLI arg, then env, then prompt.
+ for field, env_names in _ENV_ALIASES.items():
+ current = getattr(args, field, None)
+ if current:
+ continue
+ env_val = _first_env(env_names)
+ if env_val:
+ setattr(args, field, env_val)
+
+ # If a JWT file was provided, read its contents into args.jwt (unless the
+ # caller also passed --jwt directly, in which case the direct value wins).
+ jwt_file = getattr(args, "jwt_file", None)
+ if jwt_file and not getattr(args, "jwt", None):
+ try:
+ args.jwt = Path(jwt_file).read_text(encoding="utf-8").strip()
+ except OSError as exc:
+ raise SystemExit(f"Could not read --jwt-file {jwt_file!r}: {exc}") from exc
+
+ # Skip prompting entirely if the caller opted out or stdin is not a
+ # terminal. `input()` on a closed/piped stdin either blocks forever or
+ # raises EOFError; neither is what a scripted invocation wants.
+ if not allow_prompt or not sys.stdin.isatty():
+ return
+
+ # Prompt for what's still missing. We only prompt for the pieces we
+ # actually need: server URL, and one of JWT / token / username+password.
+ if not getattr(args, "server", None):
+ args.server = input("Tableau server URL: ").strip()
+
+ # Site is optional (empty string is the default site) so we don't prompt.
+
+ has_jwt = bool(getattr(args, "jwt", None))
+ has_token = bool(getattr(args, "token_name", None) and getattr(args, "token_value", None))
+ has_user = bool(getattr(args, "username", None) and getattr(args, "password", None))
+
+ if has_jwt or has_token or has_user:
+ return
+
+ # Partial info supplied -- fill in the matching missing piece. Handle
+ # both directions of each pair so a user who set only the secret half
+ # (e.g. TABLEAU_TOKEN_VALUE without TABLEAU_TOKEN_NAME) is prompted for
+ # the non-secret half, not asked to re-type the secret they already have.
+ if getattr(args, "token_name", None) and not getattr(args, "token_value", None):
+ args.token_value = getpass.getpass(f"Personal access token value for '{args.token_name}': ")
+ return
+ if getattr(args, "token_value", None) and not getattr(args, "token_name", None):
+ args.token_name = input("Personal access token name: ").strip()
+ return
+ if getattr(args, "username", None) and not getattr(args, "password", None):
+ args.password = getpass.getpass(f"Password for '{args.username}': ")
+ return
+ if getattr(args, "password", None) and not getattr(args, "username", None):
+ args.username = input("Username: ").strip()
+ return
+
+ # Fully unspecified: default to PAT since that's what the docs recommend.
+ print("No credentials found in args or environment. Sign in with a personal access token.")
+ print("(Set TABLEAU_TOKEN_NAME / TABLEAU_TOKEN_VALUE in your env or a .env file to skip this prompt.)")
+ args.token_name = input("Personal access token name: ").strip()
+ args.token_value = getpass.getpass("Personal access token value: ")
+
+
+def build_auth(args: argparse.Namespace) -> TSC.TableauAuth | TSC.PersonalAccessTokenAuth | TSC.JWTAuth:
+ """Return the appropriate auth object based on what's set on `args`.
+
+ Priority is JWT > PAT > username/password: a script that has a JWT
+ minted for a specific session should never fall back to a longer-lived
+ credential if the JWT-adjacent fields were left set by accident.
+
+ Also validates that `--server` is set. `resolve_credentials` skips prompting
+ in non-interactive contexts (CI, piped stdin), so a missing server URL would
+ otherwise reach `TSC.Server(None, ...)` and fail with a confusing error;
+ catching it here gives the caller a clear message.
+ """
+ if not getattr(args, "server", None):
+ raise ValueError(
+ "No Tableau server URL. Provide --server, set the TABLEAU_SERVER env "
+ "var, or run in an interactive terminal to be prompted."
+ )
+ site = getattr(args, "site", None) or ""
+ if getattr(args, "jwt", None):
+ return TSC.JWTAuth(args.jwt, site_id=site)
+ if getattr(args, "token_name", None) and getattr(args, "token_value", None):
+ return TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=site)
+ if getattr(args, "username", None) and getattr(args, "password", None):
+ return TSC.TableauAuth(args.username, args.password, site_id=site)
+ raise ValueError(
+ "No usable credentials found. Provide --jwt/--jwt-file, "
+ "--token-name/--token-value, --username/--password, or set the "
+ "corresponding env vars."
+ )
diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py
index c9f35d5be..88ac5ec31 100644
--- a/samples/explore_datasource.py
+++ b/samples/explore_datasource.py
@@ -14,38 +14,28 @@
import tableauserverclient as TSC
+from _shared import add_common_arguments, build_auth, resolve_credentials
+
def main():
parser = argparse.ArgumentParser(description="Explore datasource functions supported by the Server API.")
- # Common options; please keep those in sync across all samples
- parser.add_argument("--server", "-s", help="server address")
- parser.add_argument("--site", "-S", help="site name")
- parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
- parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
- parser.add_argument(
- "--logging-level",
- "-l",
- choices=["debug", "info", "error"],
- default="error",
- help="desired logging level (set to error by default)",
- )
+ add_common_arguments(parser)
# Options specific to this sample
parser.add_argument("--publish", metavar="FILEPATH", help="path to datasource to publish")
parser.add_argument("--download", metavar="FILEPATH", help="path to save downloaded datasource")
args = parser.parse_args()
- # Set logging level based on user input, or error by default
- logging_level = getattr(logging, args.logging_level.upper())
- logging.basicConfig(level=logging_level)
+ resolve_credentials(args)
+ logging.basicConfig(level=getattr(logging, args.logging_level.upper()))
- # SIGN IN
- tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
+ tableau_auth = build_auth(args)
server = TSC.Server(args.server, use_server_version=True)
with server.auth.sign_in(tableau_auth):
- # Query projects for use when demonstrating publishing and updating
- all_projects, pagination_item = server.projects.get()
- default_project = next((project for project in all_projects if project.is_default()), None)
+ # Query projects for use when demonstrating publishing and updating.
+ # Use TSC.Pager (or `.all()` / `.filter()`) to iterate every page;
+ # a raw `server.projects.get()` only returns the first page.
+ default_project = next((project for project in TSC.Pager(server.projects) if project.is_default()), None)
# Publish datasource if publish flag is set (-publish, -p)
if args.publish:
@@ -59,9 +49,12 @@ def main():
else:
print("Publish failed. Could not find the default project.")
- # Gets all datasource items
- all_datasources, pagination_item = server.datasources.get()
+ # Gets all datasource items. `.get()` returns only one page; use
+ # TSC.Pager to iterate every page. The first response also gives us
+ # the total_available count without paging through everything.
+ first_page, pagination_item = server.datasources.get()
print(f"\nThere are {pagination_item.total_available} datasources on site: ")
+ all_datasources = list(TSC.Pager(server.datasources))
print([datasource.name for datasource in all_datasources])
if all_datasources:
diff --git a/samples/explore_favorites.py b/samples/explore_favorites.py
index f199522ed..aecd06685 100644
--- a/samples/explore_favorites.py
+++ b/samples/explore_favorites.py
@@ -5,30 +5,19 @@
import tableauserverclient as TSC
from tableauserverclient.models import Resource
+from _shared import add_common_arguments, build_auth, resolve_credentials
+
def main():
parser = argparse.ArgumentParser(description="Explore favoriting functions supported by the Server API.")
- # Common options; please keep those in sync across all samples
- parser.add_argument("--server", "-s", help="server address")
- parser.add_argument("--site", "-S", help="site name")
- parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
- parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
- parser.add_argument(
- "--logging-level",
- "-l",
- choices=["debug", "info", "error"],
- default="error",
- help="desired logging level (set to error by default)",
- )
+ add_common_arguments(parser)
args = parser.parse_args()
- # Set logging level based on user input, or error by default
- logging_level = getattr(logging, args.logging_level.upper())
- logging.basicConfig(level=logging_level)
+ resolve_credentials(args)
+ logging.basicConfig(level=getattr(logging, args.logging_level.upper()))
- # SIGN IN
- tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
+ tableau_auth = build_auth(args)
server = TSC.Server(args.server, use_server_version=True)
with server.auth.sign_in(tableau_auth):
print(server)
@@ -43,8 +32,9 @@ def main():
server.favorites.get(user)
print(user.favorites)
- # get list of workbooks
- all_workbook_items, pagination_item = server.workbooks.get()
+ # get list of workbooks. `.get()` only returns one page; use
+ # TSC.Pager to iterate every workbook on the site.
+ all_workbook_items = list(TSC.Pager(server.workbooks))
if all_workbook_items is not None and len(all_workbook_items) > 0:
my_workbook = all_workbook_items[0]
server.favorites.add_favorite(user, Resource.Workbook, all_workbook_items[0])
@@ -59,25 +49,32 @@ def main():
server.favorites.add_favorite_view(user, my_view)
print(f"View added to favorites. View Name: {my_view.name}, View ID: {my_view.id}")
- all_datasource_items, pagination_item = server.datasources.get()
+ all_datasource_items = list(TSC.Pager(server.datasources))
if all_datasource_items:
my_datasource = all_datasource_items[0]
- server.favorites.add_favorite_datasource(user, my_datasource)
- print(
- "Datasource added to favorites. Datasource Name: {}, Datasource ID: {}".format(
- my_datasource.name, my_datasource.id
+ server.favorites.add_favorite_datasource(user, my_datasource)
+ print(
+ "Datasource added to favorites. Datasource Name: {}, Datasource ID: {}".format(
+ my_datasource.name, my_datasource.id
+ )
)
- )
- server.favorites.delete_favorite_workbook(user, my_workbook)
- print(f"Workbook deleted from favorites. Workbook Name: {my_workbook.name}, Workbook ID: {my_workbook.id}")
+ # Cleanup — delete the favorites we just created. Must stay inside the
+ # `with server.auth.sign_in(...)` block; a delete after sign-out fails
+ # with a not-signed-in error. Each guard mirrors the "add" check above
+ # so we do not try to delete a favorite we never created.
+ if my_workbook is not None:
+ server.favorites.delete_favorite_workbook(user, my_workbook)
+ print(f"Workbook deleted from favorites. Workbook Name: {my_workbook.name}, Workbook ID: {my_workbook.id}")
- server.favorites.delete_favorite_view(user, my_view)
- print(f"View deleted from favorites. View Name: {my_view.name}, View ID: {my_view.id}")
+ if my_view is not None:
+ server.favorites.delete_favorite_view(user, my_view)
+ print(f"View deleted from favorites. View Name: {my_view.name}, View ID: {my_view.id}")
- server.favorites.delete_favorite_datasource(user, my_datasource)
- print(
- "Datasource deleted from favorites. Datasource Name: {}, Datasource ID: {}".format(
- my_datasource.name, my_datasource.id
- )
- )
+ if my_datasource is not None:
+ server.favorites.delete_favorite_datasource(user, my_datasource)
+ print(
+ "Datasource deleted from favorites. Datasource Name: {}, Datasource ID: {}".format(
+ my_datasource.name, my_datasource.id
+ )
+ )
diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py
index f25c41849..64a73a430 100644
--- a/samples/explore_webhooks.py
+++ b/samples/explore_webhooks.py
@@ -11,37 +11,25 @@
import argparse
import logging
-import os.path
import tableauserverclient as TSC
+from _shared import add_common_arguments, build_auth, resolve_credentials
+
def main():
parser = argparse.ArgumentParser(description="Explore webhook functions supported by the Server API.")
- # Common options; please keep those in sync across all samples
- parser.add_argument("--server", "-s", help="server address")
- parser.add_argument("--site", "-S", help="site name")
- parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
- parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
- parser.add_argument(
- "--logging-level",
- "-l",
- choices=["debug", "info", "error"],
- default="error",
- help="desired logging level (set to error by default)",
- )
+ add_common_arguments(parser)
# Options specific to this sample
parser.add_argument("--create", help="create a webhook")
parser.add_argument("--delete", help="delete a webhook", action="store_true")
args = parser.parse_args()
- # Set logging level based on user input, or error by default
- logging_level = getattr(logging, args.logging_level.upper())
- logging.basicConfig(level=logging_level)
+ resolve_credentials(args)
+ logging.basicConfig(level=getattr(logging, args.logging_level.upper()))
- # SIGN IN
- tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
+ tableau_auth = build_auth(args)
server = TSC.Server(args.server, use_server_version=True)
with server.auth.sign_in(tableau_auth):
# Create webhook if create flag is set (-create, -c)
@@ -54,9 +42,11 @@ def main():
new_webhook = server.webhooks.create(new_webhook)
print(f"Webhook created. ID: {new_webhook.id}")
- # Gets all webhook items
- all_webhooks, pagination_item = server.webhooks.get()
+ # Gets all webhook items. `.get()` returns only one page; use
+ # TSC.Pager to iterate every webhook on the site.
+ first_page, pagination_item = server.webhooks.get()
print(f"\nThere are {pagination_item.total_available} webhooks on site: ")
+ all_webhooks = list(TSC.Pager(server.webhooks))
print([webhook.name for webhook in all_webhooks])
if all_webhooks:
diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py
index d537f21d6..34db59d05 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")
@@ -39,22 +30,26 @@ def main():
parser.add_argument(
"--powerpoint", "-ppt", metavar="FILENAME", help="filename (a .ppt file) to save the powerpoint deck"
)
+ parser.add_argument(
+ "--delete",
+ action="store_true",
+ help="delete the last custom view on the site after the update/export demo",
+ )
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,11 +58,14 @@ 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])
+ custom_views = []
if all_workbooks:
# Pick one workbook from the list
sample_workbook = all_workbooks[0]
@@ -123,25 +121,28 @@ 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.
+ custom_views = list(TSC.Pager(server.custom_views))
+ for c in custom_views:
print(c)
- # for the last custom view in the list
+ if custom_views:
+ # For the last custom view in the list.
+ c = custom_views[-1]
- # update the name
- # note that this will fail if the name is already changed to this value
- changed: TSC.CustomViewItem(id=c.id, name="I was updated by tsc")
- verified_change = server.custom_views.update(changed)
- print(verified_change)
+ # update the name
+ # note that this will fail if the name is already changed to this value
+ changed = TSC.CustomViewItem(id=c.id, name="I was updated by tsc")
+ verified_change = server.custom_views.update(changed)
+ print(verified_change)
- # export as image. Filters etc could be added here as usual
- server.custom_views.populate_image(c)
- filename = c.id + "-image-export.png"
- with open(filename, "wb") as f:
- f.write(c.image)
- print("saved to " + filename)
+ # export as image. Filters etc could be added here as usual
+ server.custom_views.populate_image(c)
+ filename = c.id + "-image-export.png"
+ with open(filename, "wb") as f:
+ f.write(c.image)
+ print("saved to " + filename)
if args.powerpoint:
# Populate workbook preview image
@@ -151,9 +152,12 @@ def main():
print(f"\nDownloaded powerpoint of workbook to {os.path.abspath(args.powerpoint)}")
if args.delete:
- print(f"deleting {c.id}")
- unlucky = TSC.CustomViewItem(c.id)
- server.custom_views.delete(unlucky.id)
+ if not custom_views:
+ print("--delete requested but no custom views on this site; nothing to delete.")
+ else:
+ print(f"deleting {c.id}")
+ unlucky = TSC.CustomViewItem(c.id)
+ server.custom_views.delete(unlucky.id)
if __name__ == "__main__":
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..dfc4541df 100644
--- a/samples/extracts.py
+++ b/samples/extracts.py
@@ -5,42 +5,31 @@
import argparse
import logging
-import os.path
import tableauserverclient as TSC
+from _shared import add_common_arguments, build_auth, resolve_credentials
+
def main():
parser = argparse.ArgumentParser(description="Explore extract functions supported by the Server API.")
- # Common options; please keep those in sync across all samples
- parser.add_argument("--server", "-s", help="server address")
- parser.add_argument("--site", help="site name")
- parser.add_argument("--token-name", "-tn", help="name of the personal access token used to sign into the server")
- parser.add_argument("--token-value", "-tv", help="value of the personal access token used to sign into the server")
- parser.add_argument(
- "--logging-level",
- "-l",
- choices=["debug", "info", "error"],
- default="error",
- help="desired logging level (set to error by default)",
- )
+ add_common_arguments(parser)
# Options specific to this sample
parser.add_argument("--create", action="store_true")
parser.add_argument("--delete", action="store_true")
parser.add_argument("--refresh", action="store_true")
- parser.add_argument("--workbook", required=False)
- parser.add_argument("--datasource", required=False)
+ # --workbook / --datasource are mutually exclusive; if neither is passed we
+ # fall back to picking the first workbook on the site (see below).
+ target = parser.add_mutually_exclusive_group()
+ target.add_argument("--workbook")
+ target.add_argument("--datasource")
args = parser.parse_args()
- # Set logging level based on user input, or error by default
- logging_level = getattr(logging, args.logging_level.upper())
- logging.basicConfig(level=logging_level)
+ resolve_credentials(args)
+ logging.basicConfig(level=getattr(logging, args.logging_level.upper()))
- # SIGN IN
- tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
- server = TSC.Server(args.server, use_server_version=False)
- server.add_http_options({"verify": False})
- server.use_server_version()
+ tableau_auth = build_auth(args)
+ server = TSC.Server(args.server, use_server_version=True)
with server.auth.sign_in(tableau_auth):
wb = None
ds = None
@@ -53,19 +42,25 @@ def main():
if ds is None:
raise ValueError(f"Datasource not found for id {args.datasource}")
else:
- # Gets all workbook items
- all_workbooks, pagination_item = server.workbooks.get()
+ # Gets all workbook items. `.get()` returns only the first page,
+ # so we use TSC.Pager to iterate every page.
+ first_page, pagination_item = server.workbooks.get()
print(f"\nThere are {pagination_item.total_available} workbooks on site: ")
+ all_workbooks = list(TSC.Pager(server.workbooks))
print([workbook.name for workbook in all_workbooks])
if all_workbooks:
- # Pick one workbook from the list
- wb = all_workbooks[3]
+ # Fall back to the first workbook on the site. For a real run,
+ # pass --workbook for a workbook you know has an extract.
+ wb = all_workbooks[0]
if args.create:
- print("create extract on wb ", wb.name)
- extract_job = server.workbooks.create_extract(wb, includeAll=True)
- print(extract_job)
+ if wb is None:
+ print("no workbook selected to create an extract on")
+ else:
+ print(f"create extract on workbook {wb.name}")
+ extract_job = server.workbooks.create_extract(wb, includeAll=True)
+ print(extract_job)
if args.refresh:
extract_job = None
@@ -81,9 +76,12 @@ def main():
print(extract_job)
if args.delete:
- print("delete extract on wb ", wb.name)
- jj = server.workbooks.delete_extract(wb)
- print(jj)
+ if wb is None:
+ print("no workbook selected to delete an extract from")
+ else:
+ print(f"delete extract on workbook {wb.name}")
+ jj = server.workbooks.delete_extract(wb)
+ print(jj)
if __name__ == "__main__":
diff --git a/samples/getting_started/3_hello_universe.py b/samples/getting_started/3_hello_universe.py
index a2c4301d0..057298b4d 100644
--- a/samples/getting_started/3_hello_universe.py
+++ b/samples/getting_started/3_hello_universe.py
@@ -40,7 +40,7 @@ def main():
for project in projects:
print(project.name)
- workbooks, pagination = server.datasources.get()
+ workbooks, pagination = server.workbooks.get()
if workbooks:
print(f"{pagination.total_available} workbooks")
print(workbooks[0])
diff --git a/samples/inspect_connection_details.py b/samples/inspect_connection_details.py
new file mode 100644
index 000000000..3f6be2847
--- /dev/null
+++ b/samples/inspect_connection_details.py
@@ -0,0 +1,108 @@
+####
+# This script demonstrates how to read connector-specific connection attributes
+# (Oracle service, Snowflake schema, Teradata query-band, initial SQL, etc.) that
+# the Tableau REST API's `` element does NOT expose.
+#
+# The REST API's `.populate_connections()` surface only carries generic fields
+# (id, type, server_address, server_port, username, embed_password, auth_type).
+# The full set of connector-specific attributes lives inside the workbook or
+# datasource file itself. This sample downloads the file via TSC, then parses it
+# with the community `tableaudocumentapi` library to reach the extra attributes.
+#
+# Requires:
+# pip install tableauserverclient tableaudocumentapi
+#
+# Related GitHub issues:
+# - server-client-python#1807 (Oracle service on ConnectionItem)
+# - server-client-python#1571 (database_name on ConnectionItem)
+# - server-client-python#160 (queryband, initial_sql)
+# - server-client-python#353 (SQL Server database + schema + table)
+#
+# To run the script, you must have installed Python 3.10 or later.
+####
+
+import argparse
+import logging
+import tempfile
+
+import tableauserverclient as TSC
+
+from _shared import add_common_arguments, build_auth, resolve_credentials
+
+try:
+ from tableaudocumentapi import Datasource, Workbook
+except ImportError:
+ raise SystemExit(
+ "This sample requires the tableaudocumentapi package.\n" "Install it with: pip install tableaudocumentapi"
+ )
+
+
+def dump_connections_from_datasource_file(path):
+ """Print connector-specific attributes for every connection inside a .tds/.tdsx file."""
+ ds = Datasource.from_file(path)
+ for i, c in enumerate(ds.connections, start=1):
+ _print_connection(f"datasource conn #{i}", c)
+
+
+def dump_connections_from_workbook_file(path):
+ """Print connector-specific attributes for every connection across every
+ embedded datasource inside a .twb/.twbx file."""
+ wb = Workbook(path)
+ for di, ds in enumerate(wb.datasources, start=1):
+ for ci, c in enumerate(ds.connections, start=1):
+ _print_connection(f"workbook datasource #{di}, conn #{ci}", c)
+
+
+def _print_connection(label, c):
+ print(f"--- {label} ---")
+ print(f" dbclass: {c.dbclass}")
+ print(f" server: {c.server}")
+ print(f" port: {c.port}")
+ print(f" username: {c.username}")
+ print(f" dbname: {c.dbname}")
+ # Schema, service, query-band, and initial-SQL fields are connector-specific
+ # and are not exposed by TSC's ConnectionItem today.
+ print(f" schema: {c.schema}")
+ print(f" service: {c.service}")
+ print(f" authentication: {c.authentication}")
+ print(f" query_band: {c.query_band}")
+ print(f" initial_sql: {c.initial_sql}")
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description=(
+ "Inspect connector-specific connection attributes for a datasource or workbook. "
+ "Downloads the item via TSC, then parses its embedded XML with tableau-document-api."
+ )
+ )
+ # 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("resource_type", choices=["workbook", "datasource"])
+ parser.add_argument("resource_id", help="LUID of the workbook or datasource")
+
+ 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):
+ endpoint = server.workbooks if args.resource_type == "workbook" else server.datasources
+ with tempfile.TemporaryDirectory() as tmp:
+ # include_extract=False keeps downloads small; extract bytes are not
+ # needed to read the XML.
+ path = endpoint.download(args.resource_id, filepath=tmp, include_extract=False)
+ print(f"Downloaded to {path}")
+ if args.resource_type == "workbook":
+ dump_connections_from_workbook_file(path)
+ else:
+ dump_connections_from_datasource_file(path)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/samples/list_jobs.py b/samples/list_jobs.py
new file mode 100644
index 000000000..1c9bd9e8d
--- /dev/null
+++ b/samples/list_jobs.py
@@ -0,0 +1,145 @@
+####
+# This script demonstrates how to list background jobs on a Tableau site
+# and (optionally) wait for a specific job to finish.
+#
+# Background jobs are created when you run an extract refresh, publish
+# asynchronously, run a flow, delete a site asynchronously, and so on.
+# See the REST API "Query Jobs" reference for the full list of job types.
+#
+# Examples:
+#
+# # List every job on the site, most recent first.
+# python samples/list_jobs.py
+#
+# # Only jobs from the last 24 hours.
+# python samples/list_jobs.py --hours 24
+#
+# # Only in-progress refresh_extracts jobs.
+# python samples/list_jobs.py --status InProgress --type refresh_extracts
+#
+# # Wait for a specific job to finish.
+# python samples/list_jobs.py --wait
+#
+# To run the script, you must have installed Python 3.10 or later.
+####
+
+import argparse
+import datetime
+import logging
+
+import tableauserverclient as TSC
+from tableauserverclient.server.endpoint.exceptions import JobCancelledException, JobFailedException
+
+from _shared import add_common_arguments, build_auth, resolve_credentials
+
+
+def main():
+ parser = argparse.ArgumentParser(description="List background jobs on the site, or wait for one to finish.")
+ add_common_arguments(parser)
+
+ parser.add_argument(
+ "--hours",
+ type=int,
+ help="Only show jobs created in the last N hours (uses the filter endpoint).",
+ )
+ parser.add_argument(
+ "--status",
+ help="Filter by job status, e.g. Success, Failed, InProgress, Cancelled, Pending.",
+ )
+ parser.add_argument(
+ "--type",
+ dest="job_type",
+ help="Filter by job type, e.g. refresh_extracts, publish, run_flow.",
+ )
+ parser.add_argument(
+ "--wait",
+ metavar="JOB_ID",
+ help="Instead of listing, wait for the given job ID to complete and print the result.",
+ )
+ parser.add_argument(
+ "--timeout",
+ type=float,
+ help="Max seconds to wait when --wait is used. Defaults to no timeout.",
+ )
+
+ args = parser.parse_args()
+
+ resolve_credentials(args)
+ logging.basicConfig(level=getattr(logging, args.logging_level.upper()))
+
+ tableau_auth = build_auth(args)
+ server = TSC.Server(args.server, use_server_version=True)
+
+ with server.auth.sign_in(tableau_auth):
+ if args.wait:
+ _wait_for_job(server, args.wait, args.timeout)
+ return
+
+ _list_jobs(server, args)
+
+
+def _list_jobs(server, args):
+ """List jobs using the queryset filter API, which handles pagination for us."""
+
+ # `server.jobs.filter(...)` returns a QuerySet that is directly iterable
+ # and pages through the server automatically. This is the recommended
+ # way to iterate every job on the site -- do NOT use a raw
+ # `server.jobs.get()`, which only returns the first page.
+ query = server.jobs.filter()
+
+ if args.hours is not None:
+ cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=args.hours)
+ # Filter operator suffixes: __gt / __gte / __lt / __lte / __in / __has
+ # See tableauserverclient.server.query.QuerySet for the full list.
+ # Pass the tz-aware datetime directly; QuerySet serializes it as UTC
+ # with a trailing `Z`, which older Tableau Server versions require.
+ # A raw isoformat() string can end up with a `+00:00` offset that
+ # those versions reject.
+ query = query.filter(created_at__gte=cutoff)
+
+ if args.status:
+ query = query.filter(status=args.status)
+
+ if args.job_type:
+ query = query.filter(job_type=args.job_type)
+
+ # Newest first is usually what a human wants when scanning.
+ query = query.order_by("-created_at")
+
+ printed = 0
+ for job in query:
+ # BackgroundJobItem fields: id, type, status, created_at, started_at, ended_at, ...
+ print(
+ f"{job.id} {job.type or '-':<24} {job.status or '-':<12} "
+ f"created={job.created_at} ended={job.ended_at}"
+ )
+ printed += 1
+
+ if printed == 0:
+ print("No jobs matched the given filters.")
+
+
+def _wait_for_job(server, job_id, timeout):
+ """Poll a single job until it finishes, using the built-in helper."""
+ try:
+ job = server.jobs.wait_for_job(job_id, timeout=timeout)
+ except JobCancelledException:
+ # JobCancelledException is a subclass of JobFailedException, so this
+ # branch must come first or cancelled jobs get reported as failed
+ # with the wrong exit code.
+ print(f"Job {job_id} was cancelled.")
+ raise SystemExit(2)
+ except JobFailedException as exc:
+ # The exception carries the failed JobItem so callers can inspect it.
+ # `notes` is the legacy list[str] surface; `status_notes` is the structured
+ # list[dict] shape emitted for modern job types (see JobItem docstring).
+ print(f"Job {job_id} failed: notes={exc.job.notes} status_notes={exc.job.status_notes}")
+ raise SystemExit(1) from exc
+
+ print(
+ f"Job {job_id} finished. finish_code={job.finish_code}" f" notes={job.notes} status_notes={job.status_notes}"
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/samples/login.py b/samples/login.py
index bc99385b3..13e05294f 100644
--- a/samples/login.py
+++ b/samples/login.py
@@ -1,83 +1,48 @@
####
# This script demonstrates how to log in to Tableau Server Client.
#
-# To run the script, you must have installed Python 3.7 or later.
+# To run the script, you must have installed Python 3.10 or later.
+#
+# Credentials can be supplied on the command line, from environment variables
+# (TABLEAU_SERVER, TABLEAU_SITE, TABLEAU_TOKEN_NAME, TABLEAU_TOKEN_VALUE,
+# TABLEAU_USERNAME, TABLEAU_PASSWORD, TABLEAU_JWT, TABLEAU_JWT_FILE), from a
+# `.env` file in the current working directory (or samples/, or repo root),
+# or interactively via getpass. Prefer env or a .env file over CLI args so
+# secrets do not end up in your shell history.
####
import argparse
-import getpass
import logging
-import os
import tableauserverclient as TSC
-
-def get_env(key):
- if key in os.environ:
- return os.environ[key]
- return None
+from _shared import add_common_arguments, build_auth, resolve_credentials
# If a sample has additional arguments, then it should copy this code and insert them after the call to
-# sample_define_common_options
-# If it has no additional arguments, it can just call this method
+# add_common_arguments. If it has no additional arguments, it can just call this method.
def set_up_and_log_in():
parser = argparse.ArgumentParser(description="Logs in to the server.")
- sample_define_common_options(parser)
+ add_common_arguments(parser)
args = parser.parse_args()
- if not args.server:
- args.server = get_env("SERVER")
- if not args.site:
- args.site = get_env("SITE")
- if not args.token_name:
- args.token_name = get_env("TOKEN_NAME")
- if not args.token_value:
- args.token_value = get_env("TOKEN_VALUE")
- args.logging_level = "debug"
+
+ resolve_credentials(args)
+ logging.basicConfig(level=getattr(logging, args.logging_level.upper()))
server = sample_connect_to_server(args)
print(server.server_info.get())
print(server.server_address, "site:", server.site_id, "user:", server.user_id)
-def sample_define_common_options(parser):
- # Common options; please keep these in sync across all samples by copying or calling this method directly
- parser.add_argument("--server", "-s", help="server address")
- parser.add_argument("--site", "-t", help="site name")
- auth = parser.add_mutually_exclusive_group(required=False)
- auth.add_argument("--token-name", "-tn", help="name of the personal access token used to sign into the server")
- auth.add_argument("--username", "-u", help="username to sign into the server")
-
- parser.add_argument("--token-value", "-tv", help="value of the personal access token used to sign into the server")
- parser.add_argument("--password", "-p", help="value of the password used to sign into the server")
- parser.add_argument(
- "--logging-level",
- "-l",
- choices=["debug", "info", "error"],
- default="error",
- help="desired logging level (set to error by default)",
- )
-
-
def sample_connect_to_server(args):
- if args.username:
- # Trying to authenticate using username and password.
- password = args.password or getpass.getpass("Password: ")
-
- tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.site)
- print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nUsername: {args.username}")
-
+ tableau_auth = build_auth(args)
+ if isinstance(tableau_auth, TSC.JWTAuth):
+ identifier = "JWT (Connected App)"
+ elif isinstance(tableau_auth, TSC.PersonalAccessTokenAuth):
+ identifier = f"Token name: {args.token_name}"
else:
- # Trying to authenticate using personal access tokens.
- token = args.token_value or getpass.getpass("Personal Access Token: ")
-
- tableau_auth = TSC.PersonalAccessTokenAuth(
- token_name=args.token_name, personal_access_token=token, site_id=args.site
- )
- print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nToken name: {args.token_name}")
-
- if not tableau_auth:
- raise TabError("Did not create authentication object. Check arguments.")
+ identifier = f"Username: {args.username}"
+ print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\n{identifier}")
# Only set this to False if you are running against a server you trust AND you know why the cert is broken
check_ssl_certificate = True
@@ -85,8 +50,6 @@ def sample_connect_to_server(args):
# Make sure we use an updated version of the rest apis, and pass in our cert handling choice
server = TSC.Server(args.server, use_server_version=True, http_options={"verify": check_ssl_certificate})
server.auth.sign_in(tableau_auth)
- server.version = "3.19"
-
return server
diff --git a/samples/manage_subscriptions.py b/samples/manage_subscriptions.py
new file mode 100644
index 000000000..e8d094db3
--- /dev/null
+++ b/samples/manage_subscriptions.py
@@ -0,0 +1,135 @@
+####
+# This script demonstrates how to list, create, and delete subscriptions
+# on a Tableau site.
+#
+# A subscription pairs a user, a schedule, and a target (workbook or view);
+# the user is emailed a snapshot of the target on each schedule tick.
+# See the REST API "Subscriptions" reference for full details.
+#
+# Examples:
+#
+# # List every subscription on the site.
+# python samples/manage_subscriptions.py list
+#
+# # Create a subscription for the signed-in user against a view + schedule.
+# python samples/manage_subscriptions.py create \
+# --target-type view \
+# --target-id \
+# --schedule-id \
+# --subject "Daily sales snapshot"
+#
+# # Delete an existing subscription.
+# python samples/manage_subscriptions.py delete --id
+#
+# To run the script, you must have installed Python 3.10 or later.
+####
+
+import argparse
+import logging
+
+import tableauserverclient as TSC
+
+from _shared import add_common_arguments, build_auth, resolve_credentials
+
+
+def handle_list(server, args):
+ """List every subscription on the site, iterating every page."""
+ # `server.subscriptions.get()` returns only the first page. Pass the
+ # endpoint to TSC.Pager to iterate every subscription without hand-
+ # rolling pagination logic.
+ count = 0
+ for sub in TSC.Pager(server.subscriptions):
+ print(
+ f"{sub.id} subject={sub.subject!r} "
+ f"user_id={sub.user_id} schedule_id={sub.schedule_id} target={sub.target}"
+ )
+ count += 1
+ if count == 0:
+ print("No subscriptions found on this site.")
+
+
+def handle_create(server, args):
+ """Create a new subscription for the signed-in user (unless --user-id given)."""
+ user_id = args.user_id or server.user_id
+ if not user_id:
+ raise SystemExit("Could not determine user_id. Pass --user-id or ensure sign-in succeeded.")
+
+ # The REST API expects lowercase content types ("workbook" or "view").
+ target = TSC.Target(args.target_id, args.target_type.lower())
+
+ new_sub = TSC.SubscriptionItem(
+ subject=args.subject,
+ schedule_id=args.schedule_id,
+ user_id=user_id,
+ target=target,
+ )
+ if args.message:
+ new_sub.message = args.message
+ new_sub.attach_image = args.attach_image
+ new_sub.attach_pdf = args.attach_pdf
+
+ created = server.subscriptions.create(new_sub)
+ print(f"Created subscription {created.id} for user {created.user_id} against {created.target}")
+
+
+def handle_delete(server, args):
+ """Delete a subscription by ID."""
+ server.subscriptions.delete(args.id)
+ print(f"Deleted subscription {args.id}.")
+
+
+def main():
+ parser = argparse.ArgumentParser(description="List, create, and delete Tableau subscriptions.")
+ add_common_arguments(parser)
+
+ subcommands = parser.add_subparsers(dest="command", required=True)
+
+ list_p = subcommands.add_parser("list", help="List every subscription on the site.")
+ list_p.set_defaults(func=handle_list)
+
+ create_p = subcommands.add_parser("create", help="Create a new subscription.")
+ create_p.add_argument("--target-type", required=True, choices=["Workbook", "View", "workbook", "view"])
+ create_p.add_argument("--target-id", required=True, help="ID of the workbook or view to subscribe to.")
+ create_p.add_argument(
+ "--schedule-id", required=True, help="ID of the schedule to attach to (see create_schedules.py)."
+ )
+ create_p.add_argument("--subject", required=True, help="Email subject line.")
+ create_p.add_argument("--message", help="Optional email body message.")
+ create_p.add_argument(
+ "--user-id",
+ help="User to subscribe. Defaults to the signed-in user.",
+ )
+ # BooleanOptionalAction (Python 3.9+) gives us --attach-image / --no-attach-image
+ # so users can opt out of the default PNG snapshot. Same for the PDF pair for
+ # symmetry, even though its default is False.
+ create_p.add_argument(
+ "--attach-image",
+ action=argparse.BooleanOptionalAction,
+ default=True,
+ help="Attach a PNG snapshot (default: on; pass --no-attach-image to disable).",
+ )
+ create_p.add_argument(
+ "--attach-pdf",
+ action=argparse.BooleanOptionalAction,
+ default=False,
+ help="Also attach a PDF snapshot (default: off).",
+ )
+ create_p.set_defaults(func=handle_create)
+
+ delete_p = subcommands.add_parser("delete", help="Delete a subscription by ID.")
+ delete_p.add_argument("--id", required=True, help="Subscription ID to delete.")
+ delete_p.set_defaults(func=handle_delete)
+
+ args = parser.parse_args()
+
+ resolve_credentials(args)
+ logging.basicConfig(level=getattr(logging, args.logging_level.upper()))
+
+ tableau_auth = build_auth(args)
+ server = TSC.Server(args.server, use_server_version=True)
+ with server.auth.sign_in(tableau_auth):
+ args.func(server, args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py
index e82c75cf9..c255d1dfa 100644
--- a/samples/move_workbook_sites.py
+++ b/samples/move_workbook_sites.py
@@ -4,7 +4,7 @@
# a workbook that matches a given name, download the workbook,
# and then publish it to the destination site.
#
-# To run the script, you must have installed Python 3.7 or later.
+# To run the script, you must have installed Python 3.10 or later.
####
import argparse
@@ -14,37 +14,27 @@
import tableauserverclient as TSC
+from _shared import add_common_arguments, build_auth, resolve_credentials
+
def main():
parser = argparse.ArgumentParser(
- description="Move one workbook from the"
- "default project of the default site to"
- "the default project of another site."
- )
- # Common options; please keep those in sync across all samples
- parser.add_argument("--server", "-s", help="server address")
- parser.add_argument("--site", "-S", help="site name")
- parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
- parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
- parser.add_argument(
- "--logging-level",
- "-l",
- choices=["debug", "info", "error"],
- default="error",
- help="desired logging level (set to error by default)",
+ description=(
+ "Move one workbook from the default project of the default site " "to the default project of another site."
+ )
)
+ add_common_arguments(parser)
# Options specific to this sample
parser.add_argument("--workbook-name", "-w", help="name of workbook to move")
parser.add_argument("--destination-site", "-d", help="name of site to move workbook into")
args = parser.parse_args()
- # Set logging level based on user input, or error by default
- logging_level = getattr(logging, args.logging_level.upper())
- logging.basicConfig(level=logging_level)
+ resolve_credentials(args)
+ logging.basicConfig(level=getattr(logging, args.logging_level.upper()))
# Step 1: Sign in to both sites on server
- tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
+ tableau_auth = build_auth(args)
source_server = TSC.Server(args.server)
dest_server = TSC.Server(args.server)
@@ -65,10 +55,10 @@ def main():
try:
workbook_path = source_server.workbooks.download(all_workbooks[0].id, tmpdir)
- # Step 4: Check if destination site exists, then sign in to the site
- all_sites, pagination_info = source_server.sites.get()
+ # Step 4: Check if destination site exists, then sign in to the site.
+ # Use TSC.Pager because `.get()` only returns the first page of sites.
found_destination_site = any(
- True for site in all_sites if args.destination_site.lower() == site.content_url.lower()
+ args.destination_site.lower() == site.content_url.lower() for site in TSC.Pager(source_server.sites)
)
if not found_destination_site:
error = f"No site named {args.destination_site} found."
diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py
index c674e6882..753f5a5f9 100644
--- a/samples/publish_datasource.py
+++ b/samples/publish_datasource.py
@@ -11,41 +11,29 @@
# For more information, refer to the documentations:
# (https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_datasources.htm#publish_data_source)
#
-# For signing into server, this script uses personal access tokens. For
-# more information on personal access tokens, refer to the documentations:
+# Sign-in delegates to `build_auth()` in samples/_shared.py, which accepts
+# JWT (Connected App), personal access token, or username + password (JWT
+# wins over PAT wins over username/password when more than one is set).
+# For PATs specifically, see:
# (https://help.tableau.com/current/server/en-us/security_personal_access_tokens.htm)
#
-# To run the script, you must have installed Python 3.7 or later.
+# To run the script, you must have installed Python 3.10 or later.
####
import argparse
import logging
-import os
import tableauserverclient as TSC
import tableauserverclient.datetime_helpers
-
-def get_env(key):
- if key in os.environ:
- return os.environ[key]
- return None
+from _shared import add_common_arguments, build_auth, resolve_credentials
def main():
parser = argparse.ArgumentParser(description="Publish a datasource to server.")
- # Common options; please keep those in sync across all samples
- parser.add_argument("--server", "-s", help="server address")
- parser.add_argument("--site", "-S", help="site name")
- parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
- parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
- parser.add_argument(
- "--logging-level",
- "-l",
- choices=["debug", "info", "error"],
- default="error",
- help="desired logging level (set to error by default)",
- )
+ # Common options -- credentials come from CLI args, env vars, a .env file,
+ # or an interactive prompt. See samples/_shared.py.
+ add_common_arguments(parser)
# Options specific to this sample
parser.add_argument("--file", "-f", help="filepath to the datasource to publish")
parser.add_argument("--project", help="Project within which to publish the datasource")
@@ -56,30 +44,18 @@ def main():
parser.add_argument("--conn-oauth", help="connection is configured to use oAuth", action="store_true")
args = parser.parse_args()
- if not args.server:
- args.server = get_env("SERVER")
- if not args.site:
- args.site = get_env("SITE")
- if not args.token_name:
- args.token_name = get_env("TOKEN_NAME")
- if not args.token_value:
- args.token_value = get_env("TOKEN_VALUE")
- args.logging = "debug"
- args.file = "C:/dev/tab-samples/5M.tdsx"
- args.async_ = True
+
+ resolve_credentials(args)
# Ensure that both the connection username and password are provided, or none at all
if (args.conn_username and not args.conn_password) or (not args.conn_username and args.conn_password):
parser.error("Both the connection username and password must be provided")
# Set logging level based on user input, or error by default
-
- _logger = logging.getLogger(__name__)
- _logger.setLevel(logging.DEBUG)
- _logger.addHandler(logging.StreamHandler())
+ logging.basicConfig(level=getattr(logging, args.logging_level.upper()))
# Sign in to server
- tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
+ tableau_auth = build_auth(args)
server = TSC.Server(args.server, use_server_version=True)
with server.auth.sign_in(tableau_auth):
# Empty project_id field will default the publish to the site's default project
@@ -92,6 +68,8 @@ def main():
TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, args.project)
)
projects = list(TSC.Pager(server.projects, req_options))
+ if not projects:
+ raise ValueError(f"No project named {args.project!r} on this site.")
if len(projects) > 1:
raise ValueError("The project name is not unique")
project_id = projects[0].id
@@ -123,11 +101,8 @@ def main():
new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds
)
print(
- (
- "{}Datasource published. Datasource ID: {}".format(
- new_datasource.id, tableauserverclient.datetime_helpers.timestamp()
- )
- )
+ f"[{tableauserverclient.datetime_helpers.timestamp()}] "
+ f"Datasource published. Datasource ID: {new_datasource.id}"
)
print("\t\tClosing connection")
diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py
index 077ddaddd..b83ad48cd 100644
--- a/samples/publish_workbook.py
+++ b/samples/publish_workbook.py
@@ -11,7 +11,7 @@
# For more information, refer to the documentations on 'Publish Workbook'
# (https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm)
#
-# To run the script, you must have installed Python 3.7 or later.
+# To run the script, you must have installed Python 3.10 or later.
####
import argparse
@@ -20,24 +20,20 @@
import tableauserverclient as TSC
from tableauserverclient import ConnectionCredentials, ConnectionItem
+from _shared import add_common_arguments, build_auth, resolve_credentials
+
def main():
parser = argparse.ArgumentParser(description="Publish a workbook to server.")
- # Common options; please keep those in sync across all samples
- parser.add_argument("--server", "-s", help="server address")
- parser.add_argument("--site", "-S", help="site name")
- parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
- parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
- parser.add_argument(
- "--logging-level",
- "-l",
- choices=["debug", "info", "error"],
- default="error",
- help="desired logging level (set to error by default)",
- )
+ # Common options -- credentials come from CLI args, env vars, a .env file,
+ # or an interactive prompt. See samples/_shared.py.
+ add_common_arguments(parser)
# Options specific to this sample
group = parser.add_mutually_exclusive_group(required=False)
- group.add_argument("--thumbnails-user-id", "-u", help="User ID to use for thumbnails")
+ # `-u` is already taken by --username in add_common_arguments; use `-U` here
+ # so argparse does not raise a conflicting-option-string error when the
+ # parser is built at run time (inside main()).
+ group.add_argument("--thumbnails-user-id", "-U", help="User ID to use for thumbnails")
group.add_argument("--thumbnails-group-id", "-g", help="Group ID to use for thumbnails")
parser.add_argument("--workbook-name", "-n", help="Name with which to publish the workbook")
@@ -49,13 +45,15 @@ def main():
args = parser.parse_args()
+ resolve_credentials(args)
+
# Set logging level based on user input, or error by default
logging_level = getattr(logging, args.logging_level.upper())
logging.basicConfig(level=logging_level)
# Step 1: Sign in to server.
- tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
- server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False})
+ tableau_auth = build_auth(args)
+ server = TSC.Server(args.server, use_server_version=True)
with server.auth.sign_in(tableau_auth):
# Step2: Retrieve the project id, if a project name was passed
if args.project is not None:
@@ -64,13 +62,18 @@ def main():
TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, args.project)
)
projects = list(TSC.Pager(server.projects, req_options))
+ if not projects:
+ raise ValueError(f"No project named {args.project!r} on this site.")
if len(projects) > 1:
raise ValueError("The project name is not unique")
project_id = projects[0].id
else:
# Get all the projects on server, then look for the default one.
- all_projects, pagination_item = server.projects.get()
- project_id = next((project for project in all_projects if project.is_default()), None).id
+ # Use TSC.Pager because `.get()` only returns the first page.
+ default_project = next((project for project in TSC.Pager(server.projects) if project.is_default()), None)
+ if default_project is None:
+ raise LookupError("The destination project could not be found.")
+ project_id = default_project.id
connection1 = ConnectionItem()
connection1.server_address = "mssql.test.com"
diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py
index c95000898..2fd33a1ec 100644
--- a/samples/refresh_tasks.py
+++ b/samples/refresh_tasks.py
@@ -2,7 +2,7 @@
# This script demonstrates how to use the Tableau Server Client
# to query extract refresh tasks and run them as needed.
#
-# To run the script, you must have installed Python 3.7 or later.
+# To run the script, you must have installed Python 3.10 or later.
####
import argparse
@@ -10,6 +10,8 @@
import tableauserverclient as TSC
+from _shared import add_common_arguments, build_auth, resolve_credentials
+
def handle_run(server, args):
task = server.tasks.get_by_id(args.id)
@@ -17,8 +19,8 @@ def handle_run(server, args):
def handle_list(server, _):
- tasks, pagination = server.tasks.get()
- for task in tasks:
+ # Use TSC.Pager to iterate every task; `.get()` returns only the first page.
+ for task in TSC.Pager(server.tasks):
print(f"{task}")
@@ -29,20 +31,9 @@ def handle_info(server, args):
def main():
parser = argparse.ArgumentParser(description="Get all of the refresh tasks available on a server")
- # Common options; please keep those in sync across all samples
- parser.add_argument("--server", "-s", help="server address")
- parser.add_argument("--site", "-S", help="site name")
- parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server")
- parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server")
- parser.add_argument(
- "--logging-level",
- "-l",
- choices=["debug", "info", "error"],
- default="error",
- help="desired logging level (set to error by default)",
- )
+ add_common_arguments(parser)
# Options specific to this sample
- subcommands = parser.add_subparsers()
+ subcommands = parser.add_subparsers(dest="command", required=True)
list_arguments = subcommands.add_parser("list")
list_arguments.set_defaults(func=handle_list)
@@ -57,12 +48,10 @@ def main():
args = parser.parse_args()
- # Set logging level based on user input, or error by default
- logging_level = getattr(logging, args.logging_level.upper())
- logging.basicConfig(level=logging_level)
+ resolve_credentials(args)
+ logging.basicConfig(level=getattr(logging, args.logging_level.upper()))
- # SIGN IN
- tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
+ tableau_auth = build_auth(args)
server = TSC.Server(args.server, use_server_version=True)
with server.auth.sign_in(tableau_auth):
args.func(server, args)
diff --git a/samples/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_connection_auth.py b/samples/update_connection_auth.py
index 19134e60c..12019f7c7 100644
--- a/samples/update_connection_auth.py
+++ b/samples/update_connection_auth.py
@@ -1,3 +1,41 @@
+####
+# This script updates a single connection on a datasource or workbook to embed
+# credentials. It's a generic authentication-change helper: the same code path
+# works whether you're setting username+password or moving a Snowflake
+# connection to keypair auth.
+#
+# Common authentication_type values (case-sensitive wire values):
+# - "auth-user-pass" -- username + password (canonical)
+# - "username-password" -- alternate spelling used by some connectors
+# (SAP HANA, SAP Sybase ASE, SAP NetWeaver BW,
+# Denodo, Salesforce)
+# - "auth-keypair" -- Snowflake keypair (see prerequisite below)
+# - "oauth" -- OAuth
+# - "auth-none" -- no auth on the connection
+# - "AD Service Principal" -- Azure AD Service Principal
+# - "Azure AD Password" -- Azure AD username/password
+# For the full list including connector-specific values, see the REST reference
+# link below or query an existing connection to observe its wire value.
+#
+# SECURITY: datasource_password is a positional CLI argument. For keypair auth
+# it carries the private-key material, which will leak into shell history, ps
+# output, and audit logs on the machine running this script. Prefer supplying
+# the key via an environment variable or stdin, or adapt this sample to read
+# the key from a file that is protected by filesystem permissions.
+#
+# When embed_password=True the server binds the connection to a matching
+# pre-saved credential on the site (looked up by attributes including username
+# and connection class). For keypair-auth conversions this means the Snowflake
+# private key MUST already be saved on the site under Site Settings -> Saved
+# Credentials for Data Sources before running this script. If not, the update
+# writes the new auth type into metadata but subsequent extract refreshes and
+# connection tests fail because no bound credential is found.
+#
+# See:
+# https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#update_data_source_connection
+# https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#update_workbook_connection
+####
+
import argparse
import logging
import tableauserverclient as TSC
diff --git a/samples/update_workbook_data_freshness_policy.py b/samples/update_workbook_data_freshness_policy.py
index c23e3717f..297335200 100644
--- a/samples/update_workbook_data_freshness_policy.py
+++ b/samples/update_workbook_data_freshness_policy.py
@@ -2,7 +2,7 @@
# This script demonstrates how to update workbook data freshness policy using the Tableau
# Server Client.
#
-# To run the script, you must have installed Python 3.7 or later.
+# To run the script, you must have installed Python 3.10 or later.
####
@@ -12,47 +12,39 @@
import tableauserverclient as TSC
from tableauserverclient import IntervalItem
+from _shared import add_common_arguments, build_auth, resolve_credentials
+
def main():
- parser = argparse.ArgumentParser(description="Creates sample schedules for each type of frequency.")
- # Common options; please keep those in sync across all samples
- parser.add_argument("--server", "-s", help="server address")
- parser.add_argument("--site", "-S", help="site name")
- parser.add_argument("--token-name", "-p", help="name of the personal access token " "used to sign into the server")
- parser.add_argument(
- "--token-value", "-v", help="value of the personal access token " "used to sign into the server"
- )
- parser.add_argument(
- "--logging-level",
- "-l",
- choices=["debug", "info", "error"],
- default="error",
- help="desired logging level (set to error by default)",
+ parser = argparse.ArgumentParser(
+ description="Update a workbook's data freshness policy across the supported schedule types."
)
+ add_common_arguments(parser)
# Options specific to this sample:
# This sample has no additional options, yet. If you add some, please add them here
args = parser.parse_args()
- # Set logging level based on user input, or error by default
- logging_level = getattr(logging, args.logging_level.upper())
- logging.basicConfig(level=logging_level)
+ resolve_credentials(args)
+ logging.basicConfig(level=getattr(logging, args.logging_level.upper()))
- tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
- server = TSC.Server(args.server, use_server_version=False)
- server.add_http_options({"verify": False})
- server.use_server_version()
+ tableau_auth = build_auth(args)
+ server = TSC.Server(args.server, use_server_version=True)
with server.auth.sign_in(tableau_auth):
- # Get workbook
- all_workbooks, pagination_item = server.workbooks.get()
+ # Get workbooks. `.get()` only returns the first page; iterate with
+ # TSC.Pager to see every workbook on the site.
+ _, pagination_item = server.workbooks.get()
print(f"\nThere are {pagination_item.total_available} workbooks on site: ")
+ all_workbooks = list(TSC.Pager(server.workbooks))
print([workbook.name for workbook in all_workbooks])
if all_workbooks:
- # Pick 1 workbook that has live datasource connection.
- # Assuming 1st workbook met the criteria for sample purposes
- # Data Freshness Policy is not available on extract & file-based datasource.
- sample_workbook = all_workbooks[2]
+ # Pick 1 workbook that has a live datasource connection. Data
+ # freshness policy is not available on extract or file-based
+ # datasources, so this sample will print a warning below if the
+ # chosen workbook has none. Adjust the index (or add a lookup by
+ # name) for a workbook on your site with a live connection.
+ sample_workbook = all_workbooks[0]
# Get more info from the workbook selected
# Troubleshoot: if sample_workbook_extended.data_freshness_policy.option returns with AttributeError
diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py
index 7241f23ca..cb197d47c 100644
--- a/tableauserverclient/__init__.py
+++ b/tableauserverclient/__init__.py
@@ -67,6 +67,10 @@
RequestOptions,
MissingRequiredFieldError,
FailedSignInError,
+ FlowRunCancelledException,
+ FlowRunFailedException,
+ JobCancelledException,
+ JobFailedException,
NotSignedInError,
ServerResponseError,
Filter,
@@ -98,6 +102,8 @@
"FileuploadItem",
"Filter",
"FlowItem",
+ "FlowRunCancelledException",
+ "FlowRunFailedException",
"FlowRunItem",
"get_versions",
"GroupItem",
@@ -105,6 +111,8 @@
"HourlyInterval",
"ImageRequestOptions",
"IntervalItem",
+ "JobCancelledException",
+ "JobFailedException",
"JobItem",
"JWTAuth",
"LinkedTaskFlowRunItem",
diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py
index a75112754..fae211601 100644
--- a/tableauserverclient/config.py
+++ b/tableauserverclient/config.py
@@ -8,19 +8,24 @@
class Config:
+ """Runtime configuration for TSC, controllable via environment variables."""
+
# The maximum size of a file that can be published in a single request is 64MB
@property
def FILESIZE_LIMIT_MB(self):
+ """Maximum single-request publish size in MB (env: TSC_FILESIZE_LIMIT_MB, capped at 64)."""
return min(int(os.getenv("TSC_FILESIZE_LIMIT_MB", 64)), 64)
# For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks
@property
def CHUNK_SIZE_MB(self):
+ """Chunk size in MB for multipart publish requests (env: TSC_CHUNK_SIZE_MB, default 50)."""
return int(os.getenv("TSC_CHUNK_SIZE_MB", 5 * 10)) # 5MB felt too slow, upped it to 50
# Default page size
@property
def PAGE_SIZE(self):
+ """Default page size for paginated API requests (env: TSC_PAGE_SIZE, default 100)."""
return int(os.getenv("TSC_PAGE_SIZE", 100))
diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py
index e18db4c78..ce5d60697 100644
--- a/tableauserverclient/datetime_helpers.py
+++ b/tableauserverclient/datetime_helpers.py
@@ -1,44 +1,47 @@
-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 the current local time as an HH:MM:SS string."""
+ 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 timezone implementation for use with datetime objects."""
+
+ 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):
+ """Parse a Tableau API datetime string into a UTC-aware datetime, or None if absent or unparseable."""
+ 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):
+ """Format a datetime as a Tableau API datetime string, or None if absent."""
+ if date is None:
+ return None
+
+ return date.astimezone(tz=utc).strftime(TABLEAU_DATE_FORMAT)
diff --git a/tableauserverclient/exponential_backoff.py b/tableauserverclient/exponential_backoff.py
index 69ffdc96b..8e735d73a 100644
--- a/tableauserverclient/exponential_backoff.py
+++ b/tableauserverclient/exponential_backoff.py
@@ -7,12 +7,15 @@
class ExponentialBackoffTimer:
+ """Timer for polling server-side events with exponential backoff between attempts."""
+
def __init__(self, *, timeout=None):
self.start_time = time.time()
self.timeout = timeout
self.current_sleep_interval = ASYNC_POLL_MIN_INTERVAL
def sleep(self):
+ """Sleep for the next backoff interval, raising TimeoutError if the timeout deadline has passed."""
max_sleep_time = ASYNC_POLL_MAX_INTERVAL
if self.timeout is not None:
elapsed = time.time() - self.start_time
diff --git a/tableauserverclient/filesys_helpers.py b/tableauserverclient/filesys_helpers.py
index 1663ee9e8..3e113bc58 100644
--- a/tableauserverclient/filesys_helpers.py
+++ b/tableauserverclient/filesys_helpers.py
@@ -4,11 +4,13 @@
def to_filename(string_to_sanitize):
+ """Strip characters from a string that are not safe for use in a filename."""
sanitized = (c for c in string_to_sanitize if c.isalnum() or c in ALLOWED_SPECIAL)
return "".join(sanitized)
def make_download_path(filepath, filename):
+ """Resolve a download destination path from an optional target filepath and the server-provided filename."""
download_path = None
if filepath is None:
@@ -24,6 +26,7 @@ def make_download_path(filepath, filename):
def get_file_object_size(file):
+ """Return the size in bytes of an open binary file object."""
# Returns the size of a file object
file.seek(0, os.SEEK_END)
file_size = file.tell()
@@ -32,6 +35,7 @@ def get_file_object_size(file):
def get_file_type(file):
+ """Detect the type of an open binary file by inspecting its magic bytes, returning one of 'zip', 'tde', 'xml', or 'hyper'. Raises ValueError if none match."""
# Tableau workbooks (twb) and data sources (tds) are both stored as xml files.
# Packaged workbooks (twbx) and data sources (tdsx) are zip files
# containing original files accompanied with supporting local files.
diff --git a/tableauserverclient/helpers/strings.py b/tableauserverclient/helpers/strings.py
index 7a0eaddc7..b1c868cc7 100644
--- a/tableauserverclient/helpers/strings.py
+++ b/tableauserverclient/helpers/strings.py
@@ -1,66 +1,67 @@
-from defusedxml.ElementTree import fromstring, tostring
-from functools import singledispatch
-from typing import TypeVar, 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
- return 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 _redact_any_type(bytearray(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..9ae99e398 100644
--- a/tableauserverclient/models/subscription_item.py
+++ b/tableauserverclient/models/subscription_item.py
@@ -1,140 +1,140 @@
-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:
+ 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"
diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py
index 0ba1e8eb2..85f0d96f1 100644
--- a/tableauserverclient/models/user_item.py
+++ b/tableauserverclient/models/user_item.py
@@ -1,4 +1,5 @@
import io
+import warnings
import xml.etree.ElementTree as ET
from datetime import datetime
from enum import IntEnum
@@ -32,7 +33,16 @@ class UserItem:
Parameters
----------
name: str
- The name of the user.
+ The username used to authenticate the user, NOT the person's display
+ name (that is ``fullname``). The required format depends on the site's
+ authentication scheme:
+
+ - Tableau Cloud: the sign-in email address (e.g. ``user@example.com``).
+ - Local authentication (on-prem Tableau Server): any unique username
+ for the site (e.g. ``jsmith``).
+ - Active Directory: the fully-qualified AD username, typically
+ ``SAMAccountName@FullyQualifiedDomain`` (e.g.
+ ``jsmith@corp.example.com``), or the User Principal Name (UPN).
site_role: str
The role of the user on the site.
@@ -308,6 +318,14 @@ def _set_values(
if email:
self.email = email
if auth_setting:
+ # Write directly to _auth_setting rather than going through the
+ # @property_is_enum(Auth) setter. This method is called from both
+ # CSV import and server response parsing (from_xml, populate, etc.);
+ # if the server ever returns an auth type we don't yet know about
+ # (a new Auth value in a future Tableau release), the enum guard
+ # would raise ValueError during response parsing. CSV callers
+ # already validate the auth string against CSVImport._AUTH_CANONICAL
+ # before calling here, so this write is safe.
self._auth_setting = auth_setting
if domain_name:
self._domain_name = domain_name
@@ -432,36 +450,70 @@ class ColumnType(IntEnum):
EMAIL = 6
AUTH = 7
- MAX = 7
+ # Total number of columns supported by the import format. Held outside
+ # the ColumnType enum so it can't be mistaken for a real column index.
+ COLUMN_COUNT = 8
+
+ # Lowercase -> canonical form mapping for the AUTH column. Class-level
+ # so the dict isn't rebuilt on every call to create_user_from_line /
+ # _validate_import_line_or_throw. The set of accepted values is derived
+ # from this map (see _valid_attributes[AUTH]) so there's a single
+ # source of truth.
+ _AUTH_CANONICAL: dict[str, str] = {
+ "saml": "SAML",
+ "openid": "OpenID",
+ "serverdefault": "ServerDefault",
+ "tableauidwithmfa": "TableauIDWithMFA",
+ }
# Read a csv line and create a user item populated by the given attributes
@staticmethod
def create_user_from_line(line: str):
if line is None or line is False or line == "\n" or line == "":
return None
- line = line.strip().lower()
- values: list[str] = list(map(str.strip, line.split(",")))
- user = UserItem(values[UserItem.CSVImport.ColumnType.USERNAME])
+ values: list[str] = list(map(str.strip, line.strip().split(",")))
+ if len(values) > UserItem.CSVImport.COLUMN_COUNT:
+ raise ValueError("Too many attributes for user import")
+ username = values[UserItem.CSVImport.ColumnType.USERNAME]
+ user = UserItem(username)
if len(values) > 1:
- if len(values) > UserItem.CSVImport.ColumnType.MAX:
- raise ValueError("Too many attributes for user import")
- while len(values) <= UserItem.CSVImport.ColumnType.MAX:
+ while len(values) < UserItem.CSVImport.COLUMN_COUNT:
values.append("")
site_role = UserItem.CSVImport._evaluate_site_role(
values[UserItem.CSVImport.ColumnType.LICENSE],
values[UserItem.CSVImport.ColumnType.ADMIN],
values[UserItem.CSVImport.ColumnType.PUBLISHER],
)
-
+ raw_auth = values[UserItem.CSVImport.ColumnType.AUTH]
+ if raw_auth:
+ canonical = UserItem.CSVImport._AUTH_CANONICAL.get(raw_auth.lower())
+ if canonical is None:
+ # Unknown auth value: pass it through instead of raising.
+ # TSC's _AUTH_CANONICAL is a hardcoded list that will lag
+ # server-side additions; refusing to build the UserItem
+ # here would block CSV imports against newer servers as
+ # soon as Tableau ships a new auth type. If it is a
+ # typo, the server rejects the row when the request
+ # posts. Warn so the caller has a shot at noticing.
+ warnings.warn(
+ f"Unknown auth setting {raw_auth!r}; passing through unchanged. "
+ f"Known values: {sorted(UserItem.CSVImport._AUTH_CANONICAL.values())}",
+ stacklevel=2,
+ )
+ auth = raw_auth
+ else:
+ auth = canonical
+ else:
+ auth = None
user._set_values(
None,
- values[UserItem.CSVImport.ColumnType.USERNAME],
+ username,
site_role,
None,
None,
values[UserItem.CSVImport.ColumnType.DISPLAY_NAME],
values[UserItem.CSVImport.ColumnType.EMAIL],
- values[UserItem.CSVImport.ColumnType.AUTH],
+ auth,
None,
None,
None,
@@ -493,6 +545,8 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, l
# Iterate through each field and validate the given value against hardcoded constraints
@staticmethod
def _validate_import_line_or_throw(incoming, logger) -> None:
+ # AUTH column's valid set is derived from _AUTH_CANONICAL so there's
+ # one source of truth for the accepted values.
_valid_attributes: list[list[str]] = [
[],
[],
@@ -501,20 +555,45 @@ def _validate_import_line_or_throw(incoming, logger) -> None:
["system", "site", "none", "no"], # admin
["yes", "true", "1", "no", "false", "0"], # publisher
[],
- [UserItem.Auth.SAML, UserItem.Auth.OpenID, UserItem.Auth.ServerDefault], # auth
+ list(UserItem.CSVImport._AUTH_CANONICAL.values()), # auth - normalized before comparison
]
line = list(map(str.strip, incoming.split(",")))
- if len(line) > UserItem.CSVImport.ColumnType.MAX:
- raise AttributeError("Too many attributes in line")
+ if len(line) > UserItem.CSVImport.COLUMN_COUNT:
+ raise ValueError("Too many attributes for user import")
username = line[UserItem.CSVImport.ColumnType.USERNAME.value]
logger.debug(f"> details - {username}")
UserItem.validate_username_or_throw(username)
for i in range(1, len(line)):
- logger.debug(f"column {UserItem.CSVImport.ColumnType(i).name}: {line[i]}")
- UserItem.CSVImport._validate_attribute_value(
- line[i], _valid_attributes[i], UserItem.CSVImport.ColumnType(i)
- )
+ value = line[i]
+ valid = _valid_attributes[i]
+ column = UserItem.CSVImport.ColumnType(i)
+ # normalize case for fields with a restricted value set
+ skip_validation = False
+ if valid:
+ if i == UserItem.CSVImport.ColumnType.AUTH:
+ canonical = UserItem.CSVImport._AUTH_CANONICAL.get(value.lower())
+ if canonical is not None:
+ value = canonical
+ elif value:
+ # Unknown auth value: warn and pass through instead
+ # of raising. TSC's _AUTH_CANONICAL is a hardcoded
+ # list that lags server-side additions; refusing
+ # would block CSV imports against newer servers as
+ # soon as Tableau ships a new auth type. Skip the
+ # allowlist check so the row still validates.
+ # Matches create_user_from_line's warn-and-pass.
+ warnings.warn(
+ f"Unknown auth setting {value!r}; passing through unchanged. "
+ f"Known values: {sorted(UserItem.CSVImport._AUTH_CANONICAL.values())}",
+ stacklevel=2,
+ )
+ skip_validation = True
+ else:
+ value = value.lower()
+ logger.debug(f"column {column.name}: {value}")
+ if not skip_validation:
+ UserItem.CSVImport._validate_attribute_value(value, valid, column)
# Given a restricted set of possible values, confirm the item is in that set
@staticmethod
@@ -524,7 +603,50 @@ def _validate_attribute_value(item: str, possible_values: list[str], column_type
return
if item in possible_values or possible_values == []:
return
- raise AttributeError(f"Invalid value {item} for {column_type}")
+ raise ValueError(f"Invalid value {item} for {column_type}")
+
+ # Inverse of _evaluate_site_role: decompose a site role back to (license, admin_level, publish)
+ # for writing the CSV import format.
+ @staticmethod
+ def _decompose_site_role(site_role: str) -> tuple[str, str, str]:
+ """Return (license, admin_level, publish) CSV column values for a given site role.
+
+ Legacy `UserItem.Roles` values are handled in two ways depending on whether
+ the server has a sensible modern equivalent:
+
+ - **Mapped to modern equivalents** (row emitted, server accepts): the legacy
+ roles `SiteAdministrator`, `Publisher`, `Interactor`, and `ReadOnly` each
+ map to the current-model role that best matches their historical intent
+ (SiteAdministratorExplorer, ExplorerCanPublish, Explorer, Viewer).
+ - **Emitted as `license="Invalid"`** (row rejected server-side with
+ USER_CSV_INVALID_LICENSE): the legacy roles `UnlicensedWithPublish`,
+ `ViewerWithPublish`, `Guest`, and `SupportUser` have no equivalent in the
+ current server model (`RestApiSiteRole` does not accept them on any code
+ path). Emitting `"Invalid"` preserves the per-row error semantics callers
+ of `bulk_add` had before this refactor, rather than silently coercing
+ those users to a valid-but-wrong Unlicensed account.
+
+ Round-trip note: `_evaluate_site_role(*_decompose_site_role(r)) == r` for
+ every current-model role. Two label asymmetries: `ServerAdministrator`
+ round-trips through the legacy label `SiteAdministrator` (that's the only
+ label `_evaluate_site_role` emits for `admin="System"`), and the legacy
+ roles above are folded into their modern equivalents by design.
+ """
+ _role_map: dict[str, tuple[str, str, str]] = {
+ "ServerAdministrator": ("Creator", "System", "1"),
+ "SiteAdministratorCreator": ("Creator", "Site", "1"),
+ "SiteAdministratorExplorer": ("Explorer", "Site", "1"),
+ "SiteAdministrator": ("Explorer", "Site", "1"), # legacy, mapped to SiteAdministratorExplorer
+ "Creator": ("Creator", "None", "1"),
+ "ExplorerCanPublish": ("Explorer", "None", "1"),
+ "Explorer": ("Explorer", "None", "0"),
+ "Viewer": ("Viewer", "None", "0"),
+ "Unlicensed": ("Unlicensed", "None", "0"),
+ "ReadOnly": ("Viewer", "None", "0"), # legacy, mapped to Viewer
+ "Publisher": ("Explorer", "None", "1"), # legacy, mapped to ExplorerCanPublish
+ "Interactor": ("Explorer", "None", "0"), # legacy, mapped to Explorer
+ }
+ return _role_map.get(site_role, ("Invalid", "None", "0"))
# https://help.tableau.com/current/server/en-us/csvguidelines.htm#settings_and_site_roles
# This logic is hardcoded to match the existing rules for import csv files
@@ -547,14 +669,14 @@ def _evaluate_site_role(license_level, admin_level, publisher):
else:
site_role = "SiteAdministratorExplorer"
else: # if it wasn't 'system' or 'site' then we can treat it as 'none'
- if publisher == "yes":
+ if publisher in ("yes", "true", "1"):
if license_level == "creator":
site_role = "Creator"
elif license_level == "explorer":
site_role = "ExplorerCanPublish"
else:
site_role = "Unlicensed" # is this the expected outcome?
- else: # publisher == 'no':
+ else: # publisher is "no" / "false" / "0" / any other value:
if license_level == "explorer" or license_level == "creator":
site_role = "Explorer"
elif license_level == "viewer":
diff --git a/tableauserverclient/namespace.py b/tableauserverclient/namespace.py
index 54ac46d8d..2b5e5b2b5 100644
--- a/tableauserverclient/namespace.py
+++ b/tableauserverclient/namespace.py
@@ -8,10 +8,12 @@
class UnknownNamespaceError(Exception):
- pass
+ """Raised when an XML response contains an unrecognized Tableau API namespace."""
class Namespace:
+ """Detects and stores the Tableau REST API XML namespace from server responses."""
+
def __init__(self):
self._namespace = {"t": NEW_NAMESPACE}
self._detected = False
@@ -20,6 +22,7 @@ def __call__(self):
return self._namespace
def detect(self, xml):
+ """Detect the XML namespace from raw response bytes, updating the stored namespace on first call. Raises UnknownNamespaceError if the namespace is not a recognized Tableau REST API namespace."""
if self._detected:
return
diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py
index 55288fdc9..44020419b 100644
--- a/tableauserverclient/server/__init__.py
+++ b/tableauserverclient/server/__init__.py
@@ -12,7 +12,14 @@
from tableauserverclient.server.sort import Sort
from tableauserverclient.server.server import Server
from tableauserverclient.server.pager import Pager
-from tableauserverclient.server.endpoint.exceptions import FailedSignInError, NotSignedInError
+from tableauserverclient.server.endpoint.exceptions import (
+ FailedSignInError,
+ FlowRunCancelledException,
+ FlowRunFailedException,
+ JobCancelledException,
+ JobFailedException,
+ NotSignedInError,
+)
from tableauserverclient.server.endpoint import (
Auth,
@@ -60,6 +67,10 @@
"Server",
"Pager",
"FailedSignInError",
+ "FlowRunCancelledException",
+ "FlowRunFailedException",
+ "JobCancelledException",
+ "JobFailedException",
"NotSignedInError",
"Auth",
"CustomViews",
diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py
index d944bc429..0a6eb8640 100644
--- a/tableauserverclient/server/endpoint/__init__.py
+++ b/tableauserverclient/server/endpoint/__init__.py
@@ -4,7 +4,7 @@
from tableauserverclient.server.endpoint.data_alert_endpoint import DataAlerts
from tableauserverclient.server.endpoint.databases_endpoint import Databases
from tableauserverclient.server.endpoint.datasources_endpoint import Datasources
-from tableauserverclient.server.endpoint.endpoint import Endpoint, QuerysetEndpoint
+from tableauserverclient.server.endpoint.endpoint import Endpoint, QuerysetEndpoint, DownloadableMixin
from tableauserverclient.server.endpoint.exceptions import ServerResponseError, MissingRequiredFieldError
from tableauserverclient.server.endpoint.extensions_endpoint import Extensions
from tableauserverclient.server.endpoint.favorites_endpoint import Favorites
@@ -40,6 +40,7 @@
"DataAlerts",
"Databases",
"Datasources",
+ "DownloadableMixin",
"QuerysetEndpoint",
"MissingRequiredFieldError",
"Endpoint",
diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py
index fe0c9b3da..110d38a18 100644
--- a/tableauserverclient/server/endpoint/auth_endpoint.py
+++ b/tableauserverclient/server/endpoint/auth_endpoint.py
@@ -4,7 +4,7 @@
from defusedxml.ElementTree import fromstring
-from tableauserverclient.server.endpoint.endpoint import Endpoint, api
+from tableauserverclient.server.endpoint.endpoint import Endpoint, XML_CONTENT_TYPE, api
from tableauserverclient.server.endpoint.exceptions import ServerResponseError
from tableauserverclient.server.request_factory import RequestFactory
@@ -68,20 +68,18 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr:
"""
url = f"{self.baseurl}/signin"
signin_req = RequestFactory.Auth.signin_req(auth_req)
- server_response = self.parent_srv.session.post(
- url, data=signin_req, **self.parent_srv.http_options, allow_redirects=False
+ # Route through _make_request so signin gets the same redirect handling
+ # (multi-hop, HTTPS->HTTP scheme guard, missing-Location diagnostic,
+ # hop limit) that every other endpoint uses. Explicit auth_token=None
+ # because we don't have one yet -- and self.parent_srv.auth_token
+ # raises NotSignedInError pre-signin, so post_request can't help here.
+ server_response = self._make_request(
+ self.parent_srv.session.post,
+ url,
+ content=signin_req,
+ auth_token=None,
+ content_type=XML_CONTENT_TYPE,
)
- # manually handle a redirect so that we send the correct POST request instead of GET
- # this will make e.g http://online.tableau.com work to redirect to http://east.online.tableau.com
- if server_response.status_code == 301:
- server_response = self.parent_srv.session.post(
- server_response.headers["Location"],
- data=signin_req,
- **self.parent_srv.http_options,
- allow_redirects=False,
- )
- self.parent_srv._namespace.detect(server_response.content)
- self._check_status(server_response, url)
parsed_response = fromstring(server_response.content)
site_id = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("id", None)
site_url = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("contentUrl", None)
diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py
index 30c66d7ad..f5212485a 100644
--- a/tableauserverclient/server/endpoint/datasources_endpoint.py
+++ b/tableauserverclient/server/endpoint/datasources_endpoint.py
@@ -1,10 +1,8 @@
-from email.message import Message
import copy
import json
import io
import os
-from contextlib import closing
from pathlib import Path
from typing import Literal, TYPE_CHECKING, TypedDict, TypeVar, overload
from collections.abc import Iterable, Sequence
@@ -18,7 +16,7 @@
from .schedules_endpoint import AddResponse
from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint
-from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in
+from tableauserverclient.server.endpoint.endpoint import DownloadableMixin, QuerysetEndpoint, api, parameter_added_in
from tableauserverclient.server.endpoint.exceptions import (
DUPLICATE_EXTRACT_JOB_CODE,
InternalServerError,
@@ -30,10 +28,8 @@
from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, BYTES_PER_MB, config
from tableauserverclient.filesys_helpers import (
- make_download_path,
get_file_type,
get_file_object_size,
- to_filename,
)
from tableauserverclient.helpers.logging import logger
from tableauserverclient.models import (
@@ -46,9 +42,7 @@
)
from tableauserverclient.server import RequestFactory, RequestOptions
-io_types = (io.BytesIO, io.BufferedReader)
io_types_r = (io.BytesIO, io.BufferedReader)
-io_types_w = (io.BytesIO, io.BufferedWriter)
FilePath = str | os.PathLike
FileObject = io.BufferedReader | io.BytesIO
@@ -101,7 +95,7 @@
_UNSET = object()
-class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]):
+class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem], DownloadableMixin):
def __init__(self, parent_srv: "Server") -> None:
super().__init__(parent_srv)
self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl)
@@ -1061,22 +1055,7 @@ def download_revision(
if not include_extract:
url += "?includeExtract=False"
- with closing(self.get_request(url, parameters={"stream": True})) as server_response:
- m = Message()
- m["Content-Disposition"] = server_response.headers["Content-Disposition"]
- filename = m.get_filename(failobj="")
- if isinstance(filepath, io_types_w):
- for chunk in server_response.iter_content(1024): # 1KB
- filepath.write(chunk)
- return_path = filepath
- else:
- filename = to_filename(os.path.basename(filename))
- download_path = make_download_path(filepath, filename)
- with open(download_path, "wb") as f:
- for chunk in server_response.iter_content(1024): # 1KB
- f.write(chunk)
- return_path = os.path.abspath(download_path)
-
+ return_path = self._download_content(url, filepath)
logger.info(f"Downloaded datasource revision {revision_number} to {return_path} (ID: {datasource_id})")
return return_path
diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py
index 149aab5ec..10d3cba84 100644
--- a/tableauserverclient/server/endpoint/endpoint.py
+++ b/tableauserverclient/server/endpoint/endpoint.py
@@ -1,4 +1,8 @@
-from typing_extensions import Concatenate, ParamSpec
+from email.message import Message
+import io
+import os
+from contextlib import closing
+from urllib.parse import urljoin, urlparse
from tableauserverclient import datetime_helpers as datetime
import abc
@@ -8,8 +12,10 @@
from typing import (
Any,
Callable,
+ Concatenate,
Generic,
Optional,
+ ParamSpec,
TYPE_CHECKING,
TypeVar,
Union,
@@ -18,6 +24,7 @@
from tableauserverclient.models.pagination_item import PaginationItem
from tableauserverclient.server.request_options import RequestOptions
+from tableauserverclient.filesys_helpers import to_filename, make_download_path
from tableauserverclient.server.endpoint.exceptions import (
FailedSignInError,
@@ -25,6 +32,7 @@
InternalServerError,
NonXMLResponseError,
NotSignedInError,
+ RedirectError,
)
from tableauserverclient.server.exceptions import EndpointUnavailableError
@@ -40,6 +48,21 @@
Success_codes = [200, 201, 202, 204]
+# 301/302/303/307/308 all indicate the caller should re-request at a new URL.
+# `requests`' default handler converts POST -> GET on 301/302/303, which drops
+# the POST body and breaks sign-in / addusers / publish / any write endpoint
+# whose target sits behind a redirect. We disable that and walk the chain
+# manually, keeping the original method and body across every hop.
+#
+# RFC 7231 §6.4.4 says 303 SHOULD change the method to GET on retry. We do NOT
+# follow that recommendation, deliberately: Tableau Server does not emit 303
+# for POST endpoints in normal operation (writes redirect via 301/302 in
+# proxy/HA setups), and preserving the method + body uniformly is the
+# behavior that fixes the reported bug (#1127). If a Tableau deployment ever
+# starts emitting 303 for writes, revisit; treating it identically today is
+# a conscious deviation, not an oversight.
+Redirect_codes = [301, 302, 303, 307, 308]
+
XML_CONTENT_TYPE = "text/xml"
JSON_CONTENT_TYPE = "application/json"
@@ -115,6 +138,16 @@ def _make_request(
parameters = Endpoint.set_parameters(
self.parent_srv.http_options, auth_token, content, content_type, parameters
)
+ # Manual redirect handling: see Redirect_codes comment. `requests`
+ # follows 301/302/303 by converting POST to GET (RFC-conforming but
+ # loses the body). We default it off here and re-issue the same
+ # method ourselves in _follow_redirect_if_any. Use setdefault so a
+ # caller who has a specific reason to override (e.g. a security
+ # policy that says "fail loudly on any redirect, don't silently
+ # follow it") can pass allow_redirects=True or =False on their
+ # http_options and have it respected -- the manual redirect walk
+ # is a default, not a mandate.
+ parameters.setdefault("allow_redirects", False)
logger.debug(f"request method {method.__name__}, url: {url}")
if content:
@@ -139,6 +172,7 @@ def _make_request(
raise RuntimeError
if isinstance(server_response, Exception):
raise server_response
+ server_response, url = self._follow_redirect_if_any(method, url, parameters, server_response)
self._check_status(server_response, url)
loggable_response = self.log_response_safely(server_response)
@@ -152,6 +186,101 @@ def _make_request(
return server_response
+ def _follow_redirect_if_any(
+ self,
+ method: Callable[..., "Response"],
+ url: str,
+ parameters: dict[str, Any],
+ server_response: "Response",
+ ) -> tuple["Response", str]:
+ # Walk a 301/302/303/307/308 chain up to session.max_redirects hops,
+ # preserving method and body. Rejects HTTPS -> HTTP scheme downgrades
+ # (silent security regression). Raises RedirectError on a missing
+ # Location header instead of the KeyError requests emits deep in its
+ # internals, and on exceeding the session hop limit.
+ try:
+ max_hops = int(self.parent_srv.session.max_redirects)
+ except (AttributeError, TypeError):
+ max_hops = 30 # requests' library default
+ current_url = url
+ response = server_response
+ # Not a redirect? Return immediately regardless of max_hops (including 0).
+ if response.status_code not in Redirect_codes:
+ return response, current_url
+ # Preserve requests' `response.history` semantics: the intermediate 3xx
+ # responses in receipt order, with the final non-3xx response as the
+ # returned value. Callers doing forensic debugging on `.history` see
+ # the same shape they would from requests' native follower.
+ history: list["Response"] = []
+ method_name = getattr(method, "__name__", "REQUEST").upper()
+ for _ in range(max_hops):
+ location = response.headers.get("Location")
+ if not location:
+ raise RedirectError(
+ f"{method_name} {current_url} returned HTTP {response.status_code} "
+ f"without a Location header; can't follow the redirect."
+ )
+ # Support relative Locations per RFC 7231.
+ next_url = urljoin(current_url, location)
+ current_scheme = urlparse(current_url).scheme
+ next_scheme = urlparse(next_url).scheme
+ if current_scheme == "https" and next_scheme == "http":
+ raise RedirectError(
+ f"Refusing to follow redirect from {current_url} to {next_url}: "
+ f"HTTPS -> HTTP scheme downgrade would send request data over plaintext."
+ )
+ # http -> https upgrade on the same host: promote the stored server
+ # address so subsequent requests skip this redirect round-trip.
+ # Only rewrite when the stored address's netloc exactly matches
+ # the redirected netloc to avoid pointing the client at an
+ # unrelated server (prefix matching could match e.g. "test"
+ # against a stored address of "test.other.example").
+ if current_scheme == "http" and next_scheme == "https":
+ current_parsed = urlparse(current_url)
+ next_parsed = urlparse(next_url)
+ if current_parsed.netloc == next_parsed.netloc:
+ old_address = self.parent_srv._server_address
+ old_parsed = urlparse(old_address)
+ if old_parsed.scheme == "http" and old_parsed.netloc == current_parsed.netloc:
+ new_address = "https://" + old_address[len("http://") :]
+ self.parent_srv._server_address = new_address
+ logger.info(f"Server redirected to HTTPS; updated server address to {new_address}")
+ # Auth-material policy: the request `parameters` (including the
+ # X-Tableau-Auth header and any session cookies) are forwarded
+ # to the redirect target unchanged. This is intentional and
+ # required. TSC is a client library for a specific server the
+ # caller has already agreed to trust, and customers routinely
+ # deploy Tableau Server behind reverse proxies, load balancers,
+ # and SSO front-ends that redirect between hosts within their
+ # own infrastructure (e.g. tableau.corp.example -> east.tableau.
+ # corp.example, or an SSO IdP -> the auth-callback endpoint on
+ # a different subdomain). Stripping X-Tableau-Auth on cross-
+ # host redirects would break sign-in against every such
+ # deployment. The HTTPS -> HTTP downgrade guard above (line 208)
+ # is the boundary that keeps this from becoming a security
+ # regression: once the caller connects over HTTPS, the token
+ # never leaves TLS.
+ logger.debug(f"Following {response.status_code} redirect: {current_url} -> {next_url}")
+ history.append(response)
+ current_url = next_url
+ next_response = self._blocking_request(method, current_url, parameters)
+ if next_response is None:
+ raise RuntimeError(f"No response after redirect to {current_url}")
+ if isinstance(next_response, Exception):
+ # _blocking_request already re-raises via except -> raise, so this
+ # branch is defensive; keep it to satisfy the Response|Exception|None
+ # return type.
+ raise next_response
+ response = next_response
+ if response.status_code not in Redirect_codes:
+ response.history = history
+ return response, current_url
+ # Still a redirect after max_hops hops -> loop / misconfiguration.
+ raise RedirectError(
+ f"Exceeded {max_hops} redirect hops starting from {url}; last URL attempted was {current_url}. "
+ f"Increase session.max_redirects if this is legitimate."
+ )
+
def _check_status(self, server_response: "Response", url: str | None = None):
logger.debug(f"Response status: {server_response}")
if not hasattr(server_response, "status_code"):
@@ -323,6 +452,58 @@ def wrapper(self: E, *args: P.args, **kwargs: P.kwargs) -> R:
T = TypeVar("T")
+_io_types_w = (io.BytesIO, io.BufferedWriter)
+
+FilePath = str | os.PathLike
+FileObjectW = io.BufferedWriter | io.BytesIO
+PathOrFileW = FilePath | FileObjectW
+
+
+class DownloadableMixin:
+ """Mixin for endpoints whose resources can be downloaded as binary files.
+
+ Provides a single private helper that streams a server response to a file
+ path or writable file object, avoiding copy-paste of the identical streaming
+ loop in Workbooks, Datasources, and Flows.
+ """
+
+ def _download_content(
+ self,
+ url: str,
+ filepath: PathOrFileW | None,
+ ) -> PathOrFileW:
+ """Stream content at url to filepath and return the resolved path.
+
+ Parameters
+ ----------
+ url : str
+ Fully-qualified URL whose response body should be saved.
+ filepath : PathOrFileW | None
+ Destination file path or writable file object. When None the file
+ is saved to the current working directory using the server-supplied
+ filename from the Content-Disposition header.
+
+ Returns
+ -------
+ PathOrFileW
+ The absolute file path written, or the caller-supplied file object.
+ """
+ with closing(self.get_request(url, parameters={"stream": True})) as server_response: # type: ignore[attr-defined]
+ m = Message()
+ m["Content-Disposition"] = server_response.headers["Content-Disposition"]
+ filename = m.get_filename(failobj="")
+ if isinstance(filepath, _io_types_w):
+ for chunk in server_response.iter_content(1024): # 1KB
+ filepath.write(chunk)
+ return filepath
+ else:
+ filename = to_filename(os.path.basename(filename))
+ download_path = make_download_path(filepath, filename)
+ with open(download_path, "wb") as f:
+ for chunk in server_response.iter_content(1024): # 1KB
+ f.write(chunk)
+ return os.path.abspath(download_path)
+
class QuerysetEndpoint(Endpoint, Generic[T]):
@api(version="2.0")
@@ -374,6 +555,9 @@ def fields(self: Self, *fields: str) -> QuerySet:
queryset.request_options.fields |= set(fields) | set(("_default_",))
return queryset
+ def find_by_name(self, name: str) -> list[T]:
+ return list(self.filter(name=name))
+
def only_fields(self: Self, *fields: str) -> QuerySet:
"""
Add fields to the request options. If no fields are provided, the
diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py
index 49e065ed3..2a94a2969 100644
--- a/tableauserverclient/server/endpoint/exceptions.py
+++ b/tableauserverclient/server/endpoint/exceptions.py
@@ -130,3 +130,10 @@ class FlowRunCancelledException(FlowRunFailedException):
class UnsupportedAttributeError(TableauError):
pass
+
+
+class RedirectError(TableauError):
+ # Raised when a manual redirect can't be followed safely or at all.
+ # Cases: missing Location header, HTTPS -> HTTP downgrade, redirect loop
+ # exceeding session.max_redirects. See Endpoint._follow_redirect_if_any.
+ pass
diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py
index 228792e14..abe9e3e0c 100644
--- a/tableauserverclient/server/endpoint/favorites_endpoint.py
+++ b/tableauserverclient/server/endpoint/favorites_endpoint.py
@@ -17,6 +17,18 @@
class Favorites(Endpoint):
+ """Get, add, and remove favorites for a user.
+
+ Favorites can be workbooks, views, datasources, flows, projects, metrics,
+ or collections. Retrieved favorites are stored on the target ``UserItem``
+ object as a dictionary keyed by content type (e.g. ``"workbooks"``,
+ ``"views"``, ``"datasources"``, ``"flows"``, ``"projects"``, ``"metrics"``,
+ ``"collections"``), where each value is a list of the corresponding item
+ objects.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_favorites.htm
+ """
+
@property
def baseurl(self) -> str:
return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/favorites"
@@ -24,6 +36,33 @@ def baseurl(self) -> str:
# Gets all favorites
@api(version="2.5")
def get(self, user_item: UserItem, req_options: RequestOptions | None = None) -> None:
+ """Populate the favorites on the specified user.
+
+ After calling this method, the favorites are available through
+ ``user_item.favorites``, keyed by content type.
+
+ REST API: `Get Favorites for User `_
+
+ Parameters
+ ----------
+ user_item : UserItem
+ The user for whom to retrieve favorites. The user's ``id`` attribute
+ must be set.
+
+ req_options : RequestOptions, optional
+ Request options such as page size and page number.
+
+ Returns
+ -------
+ None
+ Favorites are populated on ``user_item.favorites``.
+
+ Examples
+ --------
+ >>> server.favorites.get(user_item)
+ >>> for workbook in user_item.favorites["workbooks"]:
+ ... print(workbook.name)
+ """
logger.info(f"Querying all favorites for user {user_item.name}")
url = f"{self.baseurl}/{user_item.id}"
server_response = self.get_request(url, req_options)
@@ -33,6 +72,32 @@ def get(self, user_item: UserItem, req_options: RequestOptions | None = None) ->
@api(version="3.15")
def add_favorite(self, user_item: UserItem, content_type: str, item: TableauItem) -> "Response":
+ """Add a content item of any supported type to the user's favorites.
+
+ Type-specific helpers (``add_favorite_workbook``, ``add_favorite_view``,
+ etc.) exist for each individual content type; this method is the
+ polymorphic entry point.
+
+ REST API: `Favorites Methods `_
+
+ Parameters
+ ----------
+ user_item : UserItem
+ The user to add the favorite for.
+
+ content_type : str
+ The type of content as a string (e.g. ``"workbook"``, ``"view"``,
+ ``"datasource"``, ``"flow"``, ``"project"``, ``"metric"``).
+
+ item : TableauItem
+ The content item to favorite. Must have ``id`` and ``name``
+ attributes.
+
+ Returns
+ -------
+ requests.Response
+ The server response.
+ """
url = f"{self.baseurl}/{user_item.id}"
add_req = RequestFactory.Favorite.add_request(item.id, content_type, item.name)
server_response = self.put_request(url, add_req)
@@ -41,6 +106,22 @@ def add_favorite(self, user_item: UserItem, content_type: str, item: TableauItem
@api(version="2.0")
def add_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None:
+ """Add a workbook to the user's favorites.
+
+ REST API: `Add Workbook to Favorites `_
+
+ Parameters
+ ----------
+ user_item : UserItem
+ The user to add the favorite for.
+
+ workbook_item : WorkbookItem
+ The workbook to add to favorites.
+
+ Returns
+ -------
+ None
+ """
url = f"{self.baseurl}/{user_item.id}"
add_req = RequestFactory.Favorite.add_workbook_req(workbook_item.id, workbook_item.name)
server_response = self.put_request(url, add_req)
@@ -48,6 +129,22 @@ def add_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem
@api(version="2.0")
def add_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None:
+ """Add a view to the user's favorites.
+
+ REST API: `Add View to Favorites `_
+
+ Parameters
+ ----------
+ user_item : UserItem
+ The user to add the favorite for.
+
+ view_item : ViewItem
+ The view to add to favorites.
+
+ Returns
+ -------
+ None
+ """
url = f"{self.baseurl}/{user_item.id}"
add_req = RequestFactory.Favorite.add_view_req(view_item.id, view_item.name)
server_response = self.put_request(url, add_req)
@@ -55,6 +152,22 @@ def add_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None:
@api(version="2.3")
def add_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None:
+ """Add a datasource to the user's favorites.
+
+ REST API: `Add Data Source to Favorites `_
+
+ Parameters
+ ----------
+ user_item : UserItem
+ The user to add the favorite for.
+
+ datasource_item : DatasourceItem
+ The datasource to add to favorites.
+
+ Returns
+ -------
+ None
+ """
url = f"{self.baseurl}/{user_item.id}"
add_req = RequestFactory.Favorite.add_datasource_req(datasource_item.id, datasource_item.name)
server_response = self.put_request(url, add_req)
@@ -62,6 +175,22 @@ def add_favorite_datasource(self, user_item: UserItem, datasource_item: Datasour
@api(version="3.1")
def add_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None:
+ """Add a project to the user's favorites.
+
+ REST API: `Add Project to Favorites `_
+
+ Parameters
+ ----------
+ user_item : UserItem
+ The user to add the favorite for.
+
+ project_item : ProjectItem
+ The project to add to favorites.
+
+ Returns
+ -------
+ None
+ """
url = f"{self.baseurl}/{user_item.id}"
add_req = RequestFactory.Favorite.add_project_req(project_item.id, project_item.name)
server_response = self.put_request(url, add_req)
@@ -69,6 +198,22 @@ def add_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -
@api(version="3.3")
def add_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None:
+ """Add a flow to the user's favorites.
+
+ REST API: `Add Flow to Favorites `_
+
+ Parameters
+ ----------
+ user_item : UserItem
+ The user to add the favorite for.
+
+ flow_item : FlowItem
+ The flow to add to favorites.
+
+ Returns
+ -------
+ None
+ """
url = f"{self.baseurl}/{user_item.id}"
add_req = RequestFactory.Favorite.add_flow_req(flow_item.id, flow_item.name)
server_response = self.put_request(url, add_req)
@@ -76,6 +221,22 @@ def add_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None:
@api(version="3.3")
def add_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None:
+ """Add a metric to the user's favorites.
+
+ REST API: `Add Metric to Favorites `_
+
+ Parameters
+ ----------
+ user_item : UserItem
+ The user to add the favorite for.
+
+ metric_item : MetricItem
+ The metric to add to favorites.
+
+ Returns
+ -------
+ None
+ """
url = f"{self.baseurl}/{user_item.id}"
add_req = RequestFactory.Favorite.add_request(metric_item.id, Resource.Metric, metric_item.name)
server_response = self.put_request(url, add_req)
@@ -93,42 +254,167 @@ def add_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> N
@api(version="3.15")
def delete_favorite(self, user_item: UserItem, content_type: Resource, item: TableauItem) -> None:
+ """Remove a content item of any supported type from the user's favorites.
+
+ Type-specific helpers (``delete_favorite_workbook``,
+ ``delete_favorite_view``, etc.) exist for each individual content
+ type; this method is the polymorphic entry point.
+
+ REST API: `Favorites Methods `_
+
+ Parameters
+ ----------
+ user_item : UserItem
+ The user to remove the favorite from.
+
+ content_type : Resource
+ The ``Resource`` type of the content (e.g. ``Resource.Workbook``,
+ ``Resource.View``).
+
+ item : TableauItem
+ The content item to remove from favorites. Must have an ``id``
+ attribute.
+
+ Returns
+ -------
+ None
+
+ Examples
+ --------
+ >>> server.favorites.delete_favorite(user_item, TSC.Resource.Workbook, workbook_item)
+ """
url = f"{self.baseurl}/{user_item.id}/{content_type}/{item.id}"
logger.info(f"Removing favorite {content_type}({item.id}) for user (ID: {user_item.id})")
self.delete_request(url)
@api(version="2.0")
def delete_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None:
+ """Remove a workbook from the user's favorites.
+
+ REST API: `Delete Workbook from Favorites `_
+
+ Parameters
+ ----------
+ user_item : UserItem
+ The user to remove the favorite from.
+
+ workbook_item : WorkbookItem
+ The workbook to remove from favorites.
+
+ Returns
+ -------
+ None
+ """
url = f"{self.baseurl}/{user_item.id}/workbooks/{workbook_item.id}"
logger.info(f"Removing favorite workbook {workbook_item.id} for user (ID: {user_item.id})")
self.delete_request(url)
@api(version="2.0")
def delete_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None:
+ """Remove a view from the user's favorites.
+
+ REST API: `Delete View from Favorites `_
+
+ Parameters
+ ----------
+ user_item : UserItem
+ The user to remove the favorite from.
+
+ view_item : ViewItem
+ The view to remove from favorites.
+
+ Returns
+ -------
+ None
+ """
url = f"{self.baseurl}/{user_item.id}/views/{view_item.id}"
logger.info(f"Removing favorite view {view_item.id} for user (ID: {user_item.id})")
self.delete_request(url)
@api(version="2.3")
def delete_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None:
+ """Remove a datasource from the user's favorites.
+
+ REST API: `Delete Data Source from Favorites `_
+
+ Parameters
+ ----------
+ user_item : UserItem
+ The user to remove the favorite from.
+
+ datasource_item : DatasourceItem
+ The datasource to remove from favorites.
+
+ Returns
+ -------
+ None
+ """
url = f"{self.baseurl}/{user_item.id}/datasources/{datasource_item.id}"
logger.info(f"Removing favorite {datasource_item.id} for user (ID: {user_item.id})")
self.delete_request(url)
@api(version="3.1")
def delete_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None:
+ """Remove a project from the user's favorites.
+
+ REST API: `Delete Project from Favorites `_
+
+ Parameters
+ ----------
+ user_item : UserItem
+ The user to remove the favorite from.
+
+ project_item : ProjectItem
+ The project to remove from favorites.
+
+ Returns
+ -------
+ None
+ """
url = f"{self.baseurl}/{user_item.id}/projects/{project_item.id}"
logger.info(f"Removing favorite project {project_item.id} for user (ID: {user_item.id})")
self.delete_request(url)
@api(version="3.3")
def delete_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None:
+ """Remove a flow from the user's favorites.
+
+ REST API: `Delete Flow from Favorites `_
+
+ Parameters
+ ----------
+ user_item : UserItem
+ The user to remove the favorite from.
+
+ flow_item : FlowItem
+ The flow to remove from favorites.
+
+ Returns
+ -------
+ None
+ """
url = f"{self.baseurl}/{user_item.id}/flows/{flow_item.id}"
logger.info(f"Removing favorite flow {flow_item.id} for user (ID: {user_item.id})")
self.delete_request(url)
@api(version="3.15")
def delete_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None:
+ """Remove a metric from the user's favorites.
+
+ REST API: `Favorites Methods `_
+
+ Parameters
+ ----------
+ user_item : UserItem
+ The user to remove the favorite from.
+
+ metric_item : MetricItem
+ The metric to remove from favorites.
+
+ Returns
+ -------
+ None
+ """
url = f"{self.baseurl}/{user_item.id}/metrics/{metric_item.id}"
logger.info(f"Removing favorite metric {metric_item.id} for user (ID: {user_item.id})")
self.delete_request(url)
diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py
index ea7b59526..8595006b8 100644
--- a/tableauserverclient/server/endpoint/flows_endpoint.py
+++ b/tableauserverclient/server/endpoint/flows_endpoint.py
@@ -1,15 +1,13 @@
-from email.message import Message
import copy
import io
import logging
import os
-from contextlib import closing
from pathlib import Path
from typing import TYPE_CHECKING
from collections.abc import Iterable
from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint
-from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api
+from tableauserverclient.server.endpoint.endpoint import DownloadableMixin, QuerysetEndpoint, api
from tableauserverclient.server.endpoint.exceptions import (
DUPLICATE_EXTRACT_JOB_CODE,
InternalServerError,
@@ -21,18 +19,12 @@
from tableauserverclient.models import FlowItem, PaginationItem, ConnectionItem, JobItem
from tableauserverclient.server import RequestFactory
from tableauserverclient.filesys_helpers import (
- to_filename,
- make_download_path,
get_file_type,
get_file_object_size,
)
from tableauserverclient.server.query import QuerySet
io_types_r = (io.BytesIO, io.BufferedReader)
-io_types_w = (io.BytesIO, io.BufferedWriter)
-
-io_types_r = (io.BytesIO, io.BufferedReader)
-io_types_w = (io.BytesIO, io.BufferedWriter)
# The maximum size of a file that can be published in a single request is 64MB
FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB
@@ -55,7 +47,7 @@
PathOrFileW = FilePath | FileObjectW
-class Flows(QuerysetEndpoint[FlowItem], TaggingMixin[FlowItem]):
+class Flows(QuerysetEndpoint[FlowItem], TaggingMixin[FlowItem], DownloadableMixin):
def __init__(self, parent_srv):
super().__init__(parent_srv)
self._resource_tagger = _ResourceTagger(parent_srv)
@@ -227,22 +219,7 @@ def download(self, flow_id: str, filepath: PathOrFileW | None = None) -> PathOrF
raise ValueError(error)
url = f"{self.baseurl}/{flow_id}/content"
- with closing(self.get_request(url, parameters={"stream": True})) as server_response:
- m = Message()
- m["Content-Disposition"] = server_response.headers["Content-Disposition"]
- filename = m.get_filename(failobj="")
- if isinstance(filepath, io_types_w):
- for chunk in server_response.iter_content(1024): # 1KB
- filepath.write(chunk)
- return_path = filepath
- else:
- filename = to_filename(os.path.basename(filename))
- download_path = make_download_path(filepath, filename)
- with open(download_path, "wb") as f:
- for chunk in server_response.iter_content(1024): # 1KB
- f.write(chunk)
- return_path = os.path.abspath(download_path)
-
+ return_path = self._download_content(url, filepath)
logger.info(f"Downloaded flow to {return_path} (ID: {flow_id})")
return return_path
diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py
index 655b7cc6f..d790c036d 100644
--- a/tableauserverclient/server/endpoint/jobs_endpoint.py
+++ b/tableauserverclient/server/endpoint/jobs_endpoint.py
@@ -1,5 +1,6 @@
import logging
-from typing_extensions import Self, overload
+from typing import overload
+from typing_extensions import Self
from tableauserverclient.models import JobItem, BackgroundJobItem, PaginationItem
diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py
index f63110592..e0b529bc0 100644
--- a/tableauserverclient/server/endpoint/projects_endpoint.py
+++ b/tableauserverclient/server/endpoint/projects_endpoint.py
@@ -5,6 +5,7 @@
from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError
from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint
from tableauserverclient.server import RequestFactory, RequestOptions
+from tableauserverclient.server.filter import Filter
from tableauserverclient.models.permissions_item import PermissionsRule
from tableauserverclient.models import ProjectItem, PaginationItem, Resource
@@ -89,6 +90,76 @@ def delete(self, project_id: str) -> None:
self.delete_request(url)
logger.info(f"Deleted single project (ID: {project_id})")
+ @api(version="2.0")
+ def get_by_path(self, path: str) -> "ProjectItem | None":
+ """
+ Retrieves a project by its path. The path is a slash-separated string
+ of project names from the root to the target project, for example
+ ``"Marketing/Q1 Reports"`` or ``"/Marketing/Q1 Reports"``.
+
+ There is no native path filter in the Tableau REST API, so this method
+ walks the project hierarchy level by level using ``filter(name=...)``.
+ Each level makes one API request, so a path with *n* components issues
+ *n* requests.
+
+ At the root level, the API ``filter(name=...)`` may return projects from
+ different levels of the hierarchy that share the same name. Only
+ projects with no parent (``parentProjectId`` absent) are considered at
+ this step. For subsequent levels, the ``parentProjectId`` filter is sent
+ to the server so only direct children of the current project are
+ returned.
+
+ If multiple sibling projects share the same name at any level, the
+ first project returned by the API is used and the rest are ignored.
+
+ Parameters
+ ----------
+ path : str
+ The slash-separated path of the project. Leading and trailing
+ slashes are ignored. Empty components (e.g. from consecutive
+ slashes) are discarded.
+
+ Returns
+ -------
+ ProjectItem | None
+ The matching project, or ``None`` if no project exists at the
+ given path.
+
+ Raises
+ ------
+ ValueError
+ If ``path`` is empty or contains only slashes.
+ """
+ components = [c for c in path.split("/") if c]
+ if not components:
+ raise ValueError("Project path must not be empty.")
+
+ # Walk the hierarchy one level at a time.
+ parent_id: "str | None" = None
+ current: "ProjectItem | None" = None
+
+ for name in components:
+ opts = RequestOptions()
+ opts.filter.add(Filter(RequestOptions.Field.Name, RequestOptions.Operator.Equals, name))
+ if parent_id is not None:
+ opts.filter.add(Filter(RequestOptions.Field.ParentProjectId, RequestOptions.Operator.Equals, parent_id))
+
+ projects, _ = self.get(opts)
+
+ if parent_id is None:
+ # At the root level the API may return projects with the same
+ # name that belong to different parents; keep only top-level ones.
+ projects = [p for p in projects if p.parent_id is None]
+
+ if not projects:
+ return None
+
+ # If multiple sibling projects share the same name, take the first.
+ current = projects[0]
+ parent_id = current.id
+
+ return current
+
@api(version="2.0")
def get_by_id(self, project_id: str) -> ProjectItem:
"""
@@ -222,6 +293,32 @@ def update_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) ->
-------
list[PermissionsRule]
Returns the updated list of permissions rules.
+
+ Examples
+ --------
+ Assign a group as Project Leader on a project. ``ProjectLeader`` is
+ the capability that grants the group the ability to publish and
+ manage content in the project as if they were the project owner.
+
+ >>> import tableauserverclient as TSC
+ >>> server = TSC.Server('https://SERVERURL')
+ >>> # Login to the server
+
+ >>> project = next(p for p in TSC.Pager(server.projects) if p.name == 'Marketing')
+ >>> group = next(g for g in TSC.Pager(server.groups) if g.name == 'Marketing Leads')
+
+ >>> rule = TSC.PermissionsRule(
+ ... grantee=group,
+ ... capabilities={
+ ... TSC.Permission.Capability.ProjectLeader: TSC.Permission.Mode.Allow,
+ ... },
+ ... )
+ >>> server.projects.update_permissions(project, [rule])
+
+ Note: ``update_permissions`` REPLACES the full permissions list on the
+ project. Any existing rules not included in the call will be removed.
+ To preserve existing rules, call ``populate_permissions(project)``
+ first, then modify ``project.permissions`` and pass the full list.
"""
return self._permissions.update(item, rules)
diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py
index 48c77da66..a0b2011b8 100644
--- a/tableauserverclient/server/endpoint/users_endpoint.py
+++ b/tableauserverclient/server/endpoint/users_endpoint.py
@@ -254,11 +254,26 @@ def add(self, user_item: UserItem) -> UserItem:
To add a new user to the site you need to first create a new user_item
(from UserItem class). When you create a new user, you specify the name
of the user and their site role. For Tableau Cloud, you also specify
- the auth_setting attribute in your request. When you add user to
- Tableau Cloud, the name of the user must be the email address that is
- used to sign in to Tableau Cloud. After you add a user, Tableau Cloud
- sends the user an email invitation. The user can click the link in the
- invitation to sign in and update their full name and password.
+ the auth_setting attribute in your request. After you add a user, Tableau
+ Cloud sends the user an email invitation. The user can click the link in
+ the invitation to sign in and update their full name and password.
+
+ The value of ``user_item.name`` is the username the server uses to
+ authenticate the user, NOT the person's display name. Its required
+ format depends on the site's authentication scheme:
+
+ - Tableau Cloud: the user's email address (e.g. ``user@example.com``),
+ which is also what they sign in with.
+ - Local authentication (on-prem Tableau Server): any username unique to
+ the site (e.g. ``jsmith``).
+ - Active Directory: the fully-qualified AD username, either
+ ``SAMAccountName@FullyQualifiedDomain`` (e.g.
+ ``jsmith@corp.example.com``) or the User Principal Name (UPN) if AD
+ is configured to use UPNs. A bare ``SAMAccountName`` may not
+ resolve depending on how AD is configured.
+
+ Set the person's display name via ``user_item.fullname``; it is a
+ separate attribute.
Parameters
----------
@@ -333,9 +348,16 @@ def add(self, user_item: UserItem) -> UserItem:
>>> server = TSC.Server('https://SERVERURL')
>>> # Login to the server
- >>> new_user = TSC.UserItem(name='new_user', site_role=TSC.UserItem.Role.Unlicensed)
+ >>> # Tableau Cloud: name must be the sign-in email address
+ >>> new_user = TSC.UserItem(name='jsmith@example.com', site_role=TSC.UserItem.Role.Explorer)
+ >>> new_user.auth_setting = TSC.UserItem.Auth.TableauIDWithMFA
+ >>> new_user.fullname = 'Jane Smith'
>>> new_user = server.users.add(new_user)
+ >>> # Active Directory-backed Tableau Server: name is SAMAccountName@Domain
+ >>> ad_user = TSC.UserItem(name='jsmith@corp.example.com', site_role=TSC.UserItem.Role.Viewer)
+ >>> ad_user = server.users.add(ad_user)
+
"""
url = self.baseurl
logger.info(f"Add user {user_item.name}")
@@ -527,7 +549,7 @@ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[Us
warnings.warn("This method is deprecated, use bulk_add instead", DeprecationWarning)
created = []
failed = []
- if not filepath.find("csv"):
+ if "csv" not in filepath:
raise ValueError("Only csv files are accepted")
with open(filepath) as csv_file:
@@ -536,11 +558,9 @@ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[Us
while line and line != "":
user: UserItem = UserItem.CSVImport.create_user_from_line(line)
try:
- print(user)
result = self.add(user)
created.append(result)
except ServerResponseError as serverError:
- print("failed")
failed.append((user, serverError))
line = csv_file.readline()
return created, failed
@@ -751,6 +771,7 @@ def create_users_csv(users: Iterable[UserItem]) -> bytes:
- Admin Level
- Publish capability
- Email
+ - Auth setting
Parameters
----------
@@ -765,22 +786,7 @@ def create_users_csv(users: Iterable[UserItem]) -> bytes:
with io.StringIO() as output:
writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL)
for user in users:
- site_role = user.site_role or "Unlicensed"
- if site_role == "ServerAdministrator":
- license = "Creator"
- admin_level = "System"
- elif site_role.startswith("SiteAdministrator"):
- admin_level = "Site"
- license = site_role.replace("SiteAdministrator", "")
- else:
- license = site_role
- admin_level = ""
-
- if any(x in site_role for x in ("Creator", "Admin", "Publish")):
- publish = 1
- else:
- publish = 0
-
+ license, admin_level, publish = UserItem.CSVImport._decompose_site_role(user.site_role or "Unlicensed")
writer.writerow(
(
f"{user.domain_name}\\{user.name}" if user.domain_name else user.name,
@@ -790,6 +796,7 @@ def create_users_csv(users: Iterable[UserItem]) -> bytes:
admin_level,
publish,
user.email,
+ user.auth_setting or "",
)
)
output.seek(0)
diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py
index dd9722cbe..8310d58b3 100644
--- a/tableauserverclient/server/endpoint/workbooks_endpoint.py
+++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py
@@ -1,15 +1,13 @@
-from email.message import Message
import copy
import io
import logging
import os
-from contextlib import closing
from pathlib import Path
from tableauserverclient.models.permissions_item import PermissionsRule
from tableauserverclient.server.query import QuerySet
-from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in
+from tableauserverclient.server.endpoint.endpoint import DownloadableMixin, QuerysetEndpoint, api, parameter_added_in
from tableauserverclient.server.endpoint.exceptions import (
DUPLICATE_EXTRACT_JOB_CODE,
InternalServerError,
@@ -21,8 +19,6 @@
from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin
from tableauserverclient.filesys_helpers import (
- to_filename,
- make_download_path,
get_file_type,
get_file_object_size,
)
@@ -47,7 +43,6 @@
from tableauserverclient.server.endpoint.schedules_endpoint import AddResponse
io_types_r = (io.BytesIO, io.BufferedReader)
-io_types_w = (io.BytesIO, io.BufferedWriter)
# The maximum size of a file that can be published in a single request is 64MB
FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB
@@ -67,7 +62,7 @@
_UNSET = object()
-class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin[WorkbookItem]):
+class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin[WorkbookItem], DownloadableMixin):
def __init__(self, parent_srv: "Server") -> None:
super().__init__(parent_srv)
self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl)
@@ -1123,22 +1118,7 @@ def download_revision(
if not include_extract:
url += "?includeExtract=False"
- with closing(self.get_request(url, parameters={"stream": True})) as server_response:
- m = Message()
- m["Content-Disposition"] = server_response.headers["Content-Disposition"]
- filename = m.get_filename(failobj="")
- if isinstance(filepath, io_types_w):
- for chunk in server_response.iter_content(1024): # 1KB
- filepath.write(chunk)
- return_path = filepath
- else:
- filename = to_filename(os.path.basename(filename))
- download_path = make_download_path(filepath, filename)
- with open(download_path, "wb") as f:
- for chunk in server_response.iter_content(1024): # 1KB
- f.write(chunk)
- return_path = os.path.abspath(download_path)
-
+ return_path = self._download_content(url, filepath)
logger.info(f"Downloaded workbook revision {revision_number} to {return_path} (ID: {workbook_id})")
return return_path
diff --git a/tableauserverclient/server/filter.py b/tableauserverclient/server/filter.py
index fd90e281f..656957d87 100644
--- a/tableauserverclient/server/filter.py
+++ b/tableauserverclient/server/filter.py
@@ -1,7 +1,53 @@
+import datetime
+
+from tableauserverclient.datetime_helpers import format_datetime
from .request_options import RequestOptions
class Filter:
+ """Represents a single Tableau REST API filter clause.
+
+ A `Filter` is one entry in a `?filter=` query parameter and serializes as
+ ``::``. Use one filter per attribute, and pass
+ multiple filters via `RequestOptions.filter.add(...)` if you need to AND
+ conditions.
+
+ Special characters in filter values
+ ----------------------------------
+ The REST API's filter grammar treats several characters as delimiters. The
+ server does NOT support escaping them, so a value containing any of these
+ characters cannot be matched exactly with the `Equals` operator:
+
+ - ``,`` -- separates values in an ``in`` list.
+ - ``&`` -- separates filter clauses.
+ - ``:`` -- separates field/operator/value.
+ - ``[`` and ``]`` -- bracket an ``in`` value list.
+
+ Workaround for names containing these characters: use the ``Equals``
+ operator with an asterisk substituted for the special character. Asterisk
+ behaves as a wildcard (starts-with, ends-with, and contains matching are
+ supported by the server; see the Tableau REST API docs for "Filtering
+ and Sorting"), so for example filtering a workbook named
+ ``T(L-F,SZ&V-MY) - PC`` can be found via ``name="T(L-F*SZ*V-MY) - PC"``.
+ Wildcard filtering requires Tableau Cloud May 2023 or Tableau Server
+ 2022.1.14 or later. Post-filter the result client-side to disambiguate
+ if multiple names could match.
+
+ Parameters
+ ----------
+ field : str
+ The field to filter on (e.g. ``RequestOptions.Field.Name``).
+
+ operator : str
+ The operator to apply (e.g. ``RequestOptions.Operator.Equals``,
+ ``In``, ``GreaterThan``).
+
+ value : str | int | bool | datetime | list
+ The value to compare against. Lists require operator ``In``. Datetimes
+ must be timezone-aware and serialize as ISO-8601 UTC. Bools serialize
+ as lowercase ``true``/``false``.
+ """
+
def __init__(self, field, operator, value):
self.field = field
self.operator = operator
@@ -9,6 +55,30 @@ def __init__(self, field, operator, value):
self.value = value
def __str__(self):
+ """Return the filter as a Tableau REST API filter string.
+
+ Format: ``::``
+
+ Value serialization rules:
+ - datetime: ISO-8601 UTC, e.g. ``2023-01-01T00:00:00Z``.
+ Naive datetimes (no tzinfo) are rejected with ValueError; always
+ pass timezone-aware datetime objects.
+ - bool: lowercase ``true`` or ``false`` as required by the REST API.
+ - list: bracket-enclosed comma-separated values, e.g. ``[a,b,c]``.
+ Only valid with the ``in`` operator.
+ - All other types: ``str()`` of the value.
+ """
+ if isinstance(self._value, datetime.datetime):
+ if self._value.tzinfo is None:
+ raise ValueError(
+ "Naive datetime passed to Filter; Tableau Server requires UTC. "
+ "Use a timezone-aware datetime, e.g. "
+ "datetime.datetime(..., tzinfo=datetime.timezone.utc)."
+ )
+ return f"{self.field}:{self.operator}:{format_datetime(self._value)}"
+ if isinstance(self._value, bool):
+ # Tableau REST API requires lowercase 'true'/'false', not Python's 'True'/'False'
+ return f"{self.field}:{self.operator}:{str(self._value).lower()}"
value_string = str(self._value)
if isinstance(self._value, list):
# this should turn the string representation of the list
diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py
index eb84248b5..a57e36a85 100644
--- a/tableauserverclient/server/query.py
+++ b/tableauserverclient/server/query.py
@@ -186,6 +186,19 @@ def page_size(self: Self) -> int:
return self._pagination_item.page_size or self.request_options.pagesize
def filter(self: Self, *invalid, page_size: int | None = None, **kwargs) -> Self:
+ """Add filter clauses to the queryset.
+
+ Each keyword argument becomes one filter. Shorthand suffixes select
+ the operator (e.g. ``name__gt="A"`` -> operator ``GreaterThan``); a
+ bare keyword uses ``Equals``. See
+ https://tableau.github.io/server-client-python/docs/filter-sort for
+ the supported suffixes.
+
+ Special-character caveat: values containing ``,``, ``&``, ``:``, ``[``
+ or ``]`` cannot be matched exactly with ``Equals``; the REST filter
+ grammar treats them as delimiters and the server does not support
+ escaping. See ``Filter`` for details and the wildcard workaround.
+ """
if invalid:
raise RuntimeError("Only accepts keyword arguments.")
for kwarg_key, value in kwargs.items():
diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py
index fc4694c01..3301b3b89 100644
--- a/tableauserverclient/server/request_factory.py
+++ b/tableauserverclient/server/request_factory.py
@@ -1,12 +1,9 @@
import xml.etree.ElementTree as ET
-from typing import Any, Callable, TypeVar, TYPE_CHECKING
+from typing import Any, Callable, Concatenate, ParamSpec, TypeVar, TYPE_CHECKING
from collections.abc import Iterable
-from typing_extensions import ParamSpec
-
from requests.packages.urllib3.fields import RequestField
from requests.packages.urllib3.filepost import encode_multipart_formdata
-from typing_extensions import Concatenate
from tableauserverclient.models import *
@@ -733,13 +730,13 @@ def update_req(self, site_item: "SiteItem", parent_srv: "Server | None" = None):
site_item.custom_subscription_email_enabled
).lower()
if site_item.custom_subscription_email is not None:
- site_element.attrib["customSubscriptionEmail"] = str(site_item.custom_subscription_email).lower()
+ site_element.attrib["customSubscriptionEmail"] = str(site_item.custom_subscription_email)
if site_item.custom_subscription_footer_enabled is not None:
site_element.attrib["customSubscriptionFooterEnabled"] = str(
site_item.custom_subscription_footer_enabled
).lower()
if site_item.custom_subscription_footer is not None:
- site_element.attrib["customSubscriptionFooter"] = str(site_item.custom_subscription_footer).lower()
+ site_element.attrib["customSubscriptionFooter"] = str(site_item.custom_subscription_footer)
if site_item.ask_data_mode is not None:
site_element.attrib["askDataMode"] = str(site_item.ask_data_mode)
if site_item.named_sharing_enabled is not None:
@@ -837,13 +834,13 @@ def create_req(self, site_item: "SiteItem", parent_srv: "Server | None" = None):
site_item.custom_subscription_email_enabled
).lower()
if site_item.custom_subscription_email is not None:
- site_element.attrib["customSubscriptionEmail"] = str(site_item.custom_subscription_email).lower()
+ site_element.attrib["customSubscriptionEmail"] = str(site_item.custom_subscription_email)
if site_item.custom_subscription_footer_enabled is not None:
site_element.attrib["customSubscriptionFooterEnabled"] = str(
site_item.custom_subscription_footer_enabled
).lower()
if site_item.custom_subscription_footer is not None:
- site_element.attrib["customSubscriptionFooter"] = str(site_item.custom_subscription_footer).lower()
+ site_element.attrib["customSubscriptionFooter"] = str(site_item.custom_subscription_footer)
if site_item.ask_data_mode is not None:
site_element.attrib["askDataMode"] = str(site_item.ask_data_mode)
if site_item.named_sharing_enabled is not None:
diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py
index 5a8600908..da1c70826 100644
--- a/tableauserverclient/server/request_options.py
+++ b/tableauserverclient/server/request_options.py
@@ -351,9 +351,54 @@ def get_query_params(self):
def vf(self, name: str, value: str) -> Self:
"""Apply a filter based on a column within the view.
- Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'
- For more detail see: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_concepts_filtering_and_sorting.htm#Filter-query-views
+ Serialized to the REST API as ``vf_=``. The rules below
+ describe how the server interprets the wire value. ``vf()`` itself
+ does not apply any Tableau-specific escaping or semantic transforms
+ to your value; it only percent-encodes the value for HTTP transport,
+ which the server decodes back before applying the rules below.
+
+ **Value syntax**
+
+ - **Exact match (default):** a single value matches rows where the
+ column equals that value exactly. Case-sensitivity follows the
+ underlying data source.
+ - **OR-list:** commas separate alternatives, so ``"East,West"``
+ matches rows where the column is ``East`` OR ``West``.
+ - **Escaping** applies to two characters only, comma and backslash;
+ all other characters (``&``, ``=``, ``/``, ``#``, ``%``, ``+``,
+ quotes, brackets, etc.) pass through untouched and the server
+ treats them as literal data. The ``\\,`` escape for a literal
+ comma is documented on the Tableau REST API filtering page. The
+ ``\\\\`` escape for a literal backslash, and the observation that
+ percent-encoding is transport-only (``%2C`` and ``%5C`` reach the
+ server as ``,`` and ``\\`` and are then processed like any other
+ comma or backslash, so URL-encoding does NOT escape them), are
+ empirical -- verified end-to-end against Tableau REST API 3.30
+ in August 2026. Examples below give the wire form (what the
+ server sees after URL decoding) and the data value it matches.
+ To match a value containing a literal comma, wire form
+ ``Rock\, Paper\, Scissors`` matches data value ``Rock, Paper,
+ Scissors`` (without escaping, the comma starts an OR-list). To
+ match a literal backslash, double it: wire form ``C:\\temp\\file``
+ matches data value ``C:\temp\file``.
+ - **Empty value** (``vf_=``) is observed to override any
+ workbook-embedded filter on that column, effectively widening it
+ to all values. Not officially documented; behavior may change
+ without notice.
+ - **No wildcards, ranges, comparisons, or operators.** Characters
+ like ``*`` and ``%`` are treated as literal data, not glob or SQL
+ wildcards; ``vf_Product=Widget*`` matches only a product literally
+ named ``Widget*``. Verified end-to-end against Tableau REST API 3.30
+ in August 2026 (``vf_Product Name=*`` and ``vf_Product Name=%``
+ returned zero rows; ``vf_Product Name=META /star*/`` returned the
+ single row whose value contains a literal ``*``). If you need
+ contains / prefix / range matching, configure the equivalent
+ filter inside the workbook.
+ - **Booleans:** the only valid values are ``'true'`` and ``'false'``.
+
+ For more detail see:
+ https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_concepts_filtering_and_sorting.htm#Filter-query-views
Parameters
----------
diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py
index 6b0338970..9e5797dc4 100644
--- a/tableauserverclient/server/server.py
+++ b/tableauserverclient/server/server.py
@@ -1,8 +1,12 @@
from tableauserverclient.helpers.logging import logger
import requests
+import threading
import urllib3
import ssl
+import weakref
+
+from typing import NamedTuple, Optional
from defusedxml.ElementTree import fromstring, ParseError
from packaging.version import Version
@@ -60,6 +64,23 @@
default_server_version = "2.4" # first version that dropped the legacy auth endpoint
+class _AuthState(NamedTuple):
+ """
+ Immutable bundle of the auth fields that must change together.
+
+ The whole state is swapped by assigning a new instance, and a single
+ reference assignment or read is atomic on every CPython build,
+ free-threaded (no-GIL) builds included. Readers take one snapshot of
+ ``Server._auth_state`` and destructure it, so they can never observe a
+ token paired with the site or user of a different sign in.
+ """
+
+ site_id: Optional[str] = None
+ user_id: Optional[str] = None
+ auth_token: Optional[str] = None
+ site_url: Optional[str] = None
+
+
class Server:
"""
In the Tableau REST API, the server (https://MY-SERVER/) is the base or core
@@ -121,6 +142,18 @@ class Server:
Notes
-----
+ A single Server instance may be shared across threads (for example with
+ ``concurrent.futures.ThreadPoolExecutor``). Authentication state (the auth
+ token, site and user IDs, and API version) is shared by all threads, while
+ each thread transparently gets its own ``requests.Session`` for HTTP calls,
+ since ``requests.Session`` itself is not guaranteed to be thread-safe. Sign
+ in, and call ``use_server_version()`` if you need it, before spawning
+ worker threads; signing out invalidates the sessions of all threads.
+ ``session_factory`` may be called once per thread, so a custom factory
+ should be safe to call concurrently. Call ``close()`` (or use the Server
+ as a context manager) after worker threads finish to release the pooled
+ HTTP connections of every thread's session.
+
When using Python 3.12 or later with older versions of Tableau Server, you may encounter
SSL errors related to weak Diffie-Hellman keys. This is because newer Python versions
enforce stronger security requirements. You can temporarily work around this using
@@ -141,9 +174,7 @@ class PublishMode:
Replace = "Replace"
def __init__(self, server_address, use_server_version=False, http_options=None, session_factory=None):
- self._auth_token = None
- self._site_id = None
- self._user_id = None
+ self._auth_state = _AuthState()
self._ssl_context = None
# TODO: this needs to change to default to https, but without breaking existing code
@@ -153,6 +184,22 @@ def __init__(self, server_address, use_server_version=False, http_options=None,
self._server_address: str = server_address
self._session_factory = session_factory or requests.session
+ # Thread-safety machinery. requests.Session is not guaranteed to be
+ # thread-safe (see psf/requests#2766), so each thread that makes calls
+ # through this Server instance gets its own Session object, created
+ # lazily from session_factory. The epoch counter invalidates every
+ # thread's cached session when auth state is cleared (sign out). The
+ # lock guards the epoch counter and the session WeakSet; auth state
+ # itself is an immutable _AuthState swapped by reference and needs
+ # no lock.
+ self._session_lock = threading.Lock()
+ self._session_epoch = 0
+ self._thread_sessions = threading.local()
+ # Weak references to every session created for any thread, so close()
+ # can release their pooled connections. Weak so that sessions belonging
+ # to threads that have exited can still be garbage collected.
+ self._all_sessions: "weakref.WeakSet[requests.Session]" = weakref.WeakSet()
+
self.auth = Auth(self)
self.views = Views(self)
self.users = Users(self)
@@ -187,7 +234,6 @@ def __init__(self, server_address, use_server_version=False, http_options=None,
self.oidc = OIDC(self)
self.extensions = Extensions(self)
- self._session = self._session_factory()
self._http_options = dict() # must set this before making a server call
if http_options:
self.add_http_options(http_options)
@@ -203,7 +249,7 @@ def validate_connection_settings(self):
Endpoint.set_user_agent(params)
if not self._server_address.startswith("http://") and not self._server_address.startswith("https://"):
self._server_address = "http://" + self._server_address
- self._session.prepare_request(requests.Request("GET", url=self._server_address, params=self._http_options))
+ self.session.prepare_request(requests.Request("GET", url=self._server_address, params=self._http_options))
except Exception as req_ex:
raise ValueError("Server connection settings not valid", req_ex)
@@ -226,21 +272,64 @@ def clear_http_options(self):
self._http_options = dict()
def _clear_auth(self):
- self._site_id = None
- self._user_id = None
- self._auth_token = None
- self._site_url = None
- self._session = self._session_factory()
+ # swapping the whole immutable state in one assignment is atomic on
+ # every CPython build, free-threaded (no-GIL) builds included
+ self._auth_state = _AuthState()
+ with self._session_lock:
+ # Invalidate the cached session of every thread so state such as
+ # cookies does not leak into a later sign in. Sessions are replaced
+ # lazily on next use rather than closed here, so requests already
+ # in flight on other threads are not disrupted (this matches the
+ # previous behavior of re-assigning the shared session). The lock
+ # guards the epoch increment, which is not atomic without the GIL.
+ self._session_epoch += 1
def _set_auth(self, site_id, user_id, auth_token, site_url=None):
- self._site_id = site_id
- self._user_id = user_id
- self._auth_token = auth_token
- self._site_url = site_url
+ # swapping the whole immutable state in one assignment is atomic on
+ # every CPython build, free-threaded (no-GIL) builds included, so
+ # readers can never observe a partially-updated state
+ self._auth_state = _AuthState(site_id, user_id, auth_token, site_url)
+
+ # Backwards-compatible access to the individual auth fields. Existing code
+ # (including this project's tests) reads and assigns these directly as a
+ # sign-in shortcut. Each assignment swaps in a fully-formed state, so
+ # readers never see torn fields; assigning fields one at a time is not
+ # atomic as a group, though, so concurrent writers should use _set_auth.
+ @property
+ def _auth_token(self):
+ return self._auth_state.auth_token
+
+ @_auth_token.setter
+ def _auth_token(self, value) -> None:
+ self._auth_state = self._auth_state._replace(auth_token=value)
+
+ @property
+ def _site_id(self):
+ return self._auth_state.site_id
+
+ @_site_id.setter
+ def _site_id(self, value) -> None:
+ self._auth_state = self._auth_state._replace(site_id=value)
+
+ @property
+ def _user_id(self):
+ return self._auth_state.user_id
+
+ @_user_id.setter
+ def _user_id(self, value) -> None:
+ self._auth_state = self._auth_state._replace(user_id=value)
+
+ @property
+ def _site_url(self):
+ return self._auth_state.site_url
+
+ @_site_url.setter
+ def _site_url(self, value) -> None:
+ self._auth_state = self._auth_state._replace(site_url=value)
def _get_legacy_version(self):
# the serverInfo call was introduced in 2.4, earlier than that we have this different call
- response = self._session.get(self.server_address + "/auth?format=xml")
+ response = self.session.get(self.server_address + "/auth?format=xml")
try:
info_xml = fromstring(response.content)
except ParseError as parseError:
@@ -294,31 +383,37 @@ def namespace(self):
@property
def auth_token(self):
- if self._auth_token is None:
+ # read the state once: a second self._auth_state read could observe
+ # a concurrent sign out and return None instead of raising
+ token = self._auth_state.auth_token
+ if token is None:
error = "Missing authentication token. You must sign in first."
raise NotSignedInError(error)
- return self._auth_token
+ return token
@property
def site_id(self):
- if self._site_id is None:
+ site_id = self._auth_state.site_id
+ if site_id is None:
error = "Missing site ID. You must sign in first."
raise NotSignedInError(error)
- return self._site_id
+ return site_id
@property
def site_url(self):
- if self._site_url is None:
+ site_url = self._auth_state.site_url
+ if site_url is None:
error = "Missing site URL. You must sign in first."
raise NotSignedInError(error)
- return self._site_url
+ return site_url
@property
def user_id(self):
- if self._user_id is None:
+ user_id = self._auth_state.user_id
+ if user_id is None:
error = "Missing user ID. You must sign in first."
raise NotSignedInError(error)
- return self._user_id
+ return user_id
@property
def server_address(self):
@@ -329,11 +424,50 @@ def http_options(self):
return self._http_options
@property
- def session(self):
- return self._session
+ def session(self) -> requests.Session:
+ """
+ The requests.Session used for HTTP calls made by the current thread.
+
+ requests.Session is not guaranteed to be thread-safe (see
+ psf/requests#2766), so each thread that makes calls through this Server
+ instance transparently gets its own Session object, created from
+ ``session_factory``. Sessions are cached per thread, so a thread pool
+ worker reuses its session (and its connection pool) across tasks.
+ Signing out invalidates the cached sessions of all threads.
+ """
+ local = self._thread_sessions
+ epoch = self._session_epoch
+ if getattr(local, "session", None) is None or local.epoch != epoch:
+ session = self._session_factory()
+ with self._session_lock:
+ self._all_sessions.add(session)
+ local.session = session
+ # `epoch` was read before the factory ran: if a sign out happened
+ # in between, local.epoch is already stale and the session will be
+ # replaced on the next access.
+ local.epoch = epoch
+ return local.session
+
+ # Backwards-compatible access to the pre-thread-safety private attribute.
+ # Reading returns the current thread's session; assigning replaces the
+ # CURRENT thread's session only (other threads keep sessions created by
+ # session_factory), which preserves the common single-threaded pattern of
+ # injecting a prepared session before making calls. The injected session
+ # is registered so close() still reaches it.
+ @property
+ def _session(self) -> requests.Session:
+ return self.session
+
+ @_session.setter
+ def _session(self, value: requests.Session) -> None:
+ local = self._thread_sessions
+ with self._session_lock:
+ self._all_sessions.add(value)
+ local.session = value
+ local.epoch = self._session_epoch
def is_signed_in(self):
- return self._auth_token is not None
+ return self._auth_state.auth_token is not None
def configure_ssl(self, *, allow_weak_dh=False):
"""Configure SSL/TLS settings for the server connection.
@@ -357,3 +491,33 @@ def configure_ssl(self, *, allow_weak_dh=False):
# Remove any custom SSL context if we're reverting to default settings
if "verify" in self._http_options:
del self._http_options["verify"]
+
+ def close(self) -> None:
+ """
+ Release the pooled HTTP connections held by every thread's session.
+
+ Call this when you are done with the server, after any worker threads
+ have finished their requests. Closing is a transport-level operation:
+ it does not sign out, so the auth token remains valid on the server
+ (use ``auth.sign_out()`` for that). The Server object remains usable
+ after close; any subsequent call transparently creates a new session.
+ """
+ with self._session_lock:
+ sessions = list(self._all_sessions)
+ self._all_sessions.clear()
+ # Invalidate every thread's cached (now closed) session so later
+ # use creates a fresh one instead of hitting closed pools.
+ self._session_epoch += 1
+ for session in sessions:
+ session.close()
+
+ def __enter__(self) -> "Server":
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+ # Note: does not sign out. server.auth.sign_in() already returns a
+ # context manager that signs out; nesting the two composes cleanly:
+ # with TSC.Server(...) as server:
+ # with server.auth.sign_in(auth):
+ # ...
+ self.close()
diff --git a/test/assets/project_get_by_name_ambiguous_root.xml b/test/assets/project_get_by_name_ambiguous_root.xml
new file mode 100644
index 000000000..b2b65ed64
--- /dev/null
+++ b/test/assets/project_get_by_name_ambiguous_root.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/test/assets/project_get_by_name_child.xml b/test/assets/project_get_by_name_child.xml
new file mode 100644
index 000000000..2ce185c1d
--- /dev/null
+++ b/test/assets/project_get_by_name_child.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/test/assets/project_get_by_name_duplicate_siblings.xml b/test/assets/project_get_by_name_duplicate_siblings.xml
new file mode 100644
index 000000000..68f73b3bd
--- /dev/null
+++ b/test/assets/project_get_by_name_duplicate_siblings.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/test/assets/project_get_by_name_grandchild.xml b/test/assets/project_get_by_name_grandchild.xml
new file mode 100644
index 000000000..70ef855c0
--- /dev/null
+++ b/test/assets/project_get_by_name_grandchild.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/test/assets/project_get_by_name_top_level.xml b/test/assets/project_get_by_name_top_level.xml
new file mode 100644
index 000000000..df7cdb38e
--- /dev/null
+++ b/test/assets/project_get_by_name_top_level.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/test/assets/project_get_empty.xml b/test/assets/project_get_empty.xml
new file mode 100644
index 000000000..d8c206124
--- /dev/null
+++ b/test/assets/project_get_empty.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/test/test_datasource.py b/test/test_datasource.py
index c4bca0a7b..95332c6db 100644
--- a/test/test_datasource.py
+++ b/test/test_datasource.py
@@ -3,6 +3,7 @@
from pathlib import Path
import tempfile
import unittest
+import unittest.mock
from zipfile import ZipFile
from defusedxml.ElementTree import fromstring
diff --git a/test/test_filter.py b/test/test_filter.py
index 460813dd5..8d3ec541e 100644
--- a/test/test_filter.py
+++ b/test/test_filter.py
@@ -1,3 +1,5 @@
+import datetime
+
import tableauserverclient as TSC
@@ -14,3 +16,110 @@ def test_filter_in():
filter = TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, projects_to_find)
assert str(filter) == "name:in:[default,Salesforce Sales Projeśt]"
+
+
+def test_filter_in_single_value():
+ """A single-element list produces valid bracket syntax."""
+ filter = TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["sample"])
+
+ assert str(filter) == "tags:in:[sample]"
+
+
+def test_filter_in_multiple_values():
+ """Multi-element list produces comma-separated values inside brackets."""
+ filter = TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["a", "b", "c"])
+
+ assert str(filter) == "tags:in:[a,b,c]"
+
+
+def test_filter_integer_value():
+ """Integer filter values are serialized as plain decimal strings."""
+ filter = TSC.Filter(TSC.RequestOptions.Field.Size, TSC.RequestOptions.Operator.GreaterThan, 0)
+
+ assert str(filter) == "size:gt:0"
+
+
+def test_filter_integer_nonzero():
+ filter = TSC.Filter(TSC.RequestOptions.Field.SheetCount, TSC.RequestOptions.Operator.GreaterThanOrEqual, 5)
+
+ assert str(filter) == "sheetCount:gte:5"
+
+
+def test_filter_date_no_encoding():
+ """Date filter values should not have colons pre-encoded (fixes #1025).
+
+ The requests library handles URL encoding of the whole filter parameter,
+ so pre-encoding colons in datetime values causes double-encoding on the wire.
+ """
+ utc = datetime.timezone.utc
+ dt = datetime.datetime(2023, 1, 1, 0, 0, 0, tzinfo=utc)
+ filter = TSC.Filter(TSC.RequestOptions.Field.CreatedAt, TSC.RequestOptions.Operator.LessThan, dt)
+
+ result = str(filter)
+ assert result == "createdAt:lt:2023-01-01T00:00:00Z"
+ assert "%3A" not in result, "Colons in datetime values must not be percent-encoded"
+
+
+def test_filter_date_uses_tableau_format():
+ """Datetime values are serialized in Tableau ISO-8601 format, not Python default."""
+ utc = datetime.timezone.utc
+ dt = datetime.datetime(2024, 6, 15, 12, 30, 45, tzinfo=utc)
+ filter = TSC.Filter(TSC.RequestOptions.Field.UpdatedAt, TSC.RequestOptions.Operator.GreaterThan, dt)
+
+ result = str(filter)
+ # Must use 'T' separator and 'Z' suffix, not Python's space-separated format
+ assert result == "updatedAt:gt:2024-06-15T12:30:45Z"
+ assert " " not in result.split(":", 2)[2], "Datetime value must not contain a space (Python default format)"
+
+
+def test_filter_date_non_utc_converted():
+ """Non-UTC datetime values are converted to UTC before serialization."""
+ eastern = datetime.timezone(datetime.timedelta(hours=-5))
+ dt = datetime.datetime(2023, 3, 10, 12, 0, 0, tzinfo=eastern)
+ filter = TSC.Filter(TSC.RequestOptions.Field.CreatedAt, TSC.RequestOptions.Operator.Equals, dt)
+
+ result = str(filter)
+ assert result == "createdAt:eq:2023-03-10T17:00:00Z"
+
+
+def test_filter_bool_true():
+ """Boolean True is serialized as lowercase 'true' for Tableau REST API."""
+ filter = TSC.Filter(TSC.RequestOptions.Field.IsCertified, TSC.RequestOptions.Operator.Equals, True)
+
+ result = str(filter)
+ assert result == "isCertified:eq:true"
+ assert "True" not in result, "Boolean True must be lowercase 'true'"
+
+
+def test_filter_bool_false():
+ """Boolean False is serialized as lowercase 'false' for Tableau REST API."""
+ filter = TSC.Filter(TSC.RequestOptions.Field.IsCertified, TSC.RequestOptions.Operator.Equals, False)
+
+ result = str(filter)
+ assert result == "isCertified:eq:false"
+ assert "False" not in result, "Boolean False must be lowercase 'false'"
+
+
+def test_filter_bool_has_extracts():
+ """Boolean filter works for hasExtracts field."""
+ filter = TSC.Filter(TSC.RequestOptions.Field.HasExtracts, TSC.RequestOptions.Operator.Equals, True)
+
+ assert str(filter) == "hasExtracts:eq:true"
+
+
+def test_filter_date_naive_raises():
+ """A naive datetime (no tzinfo) raises ValueError with a helpful message."""
+ import pytest
+
+ naive_dt = datetime.datetime(2023, 1, 1, 12, 0, 0) # no tzinfo
+ f = TSC.Filter(TSC.RequestOptions.Field.CreatedAt, TSC.RequestOptions.Operator.Equals, naive_dt)
+ with pytest.raises(ValueError, match="Naive datetime"):
+ str(f)
+
+
+def test_filter_list_rejects_non_in_operator():
+ """A list value with a non-In operator raises ValueError."""
+ import pytest
+
+ with pytest.raises(ValueError):
+ TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.Equals, ["a", "b"])
diff --git a/test/test_import_surface.py b/test/test_import_surface.py
new file mode 100644
index 000000000..d2069aa64
--- /dev/null
+++ b/test/test_import_surface.py
@@ -0,0 +1,16 @@
+import tableauserverclient as TSC
+from tableauserverclient.server.endpoint import exceptions
+
+
+def test_job_exceptions_at_top_level():
+ assert TSC.JobFailedException is exceptions.JobFailedException
+ assert TSC.JobCancelledException is exceptions.JobCancelledException
+ assert "JobFailedException" in TSC.__all__
+ assert "JobCancelledException" in TSC.__all__
+
+
+def test_flow_run_exceptions_at_top_level():
+ assert TSC.FlowRunFailedException is exceptions.FlowRunFailedException
+ assert TSC.FlowRunCancelledException is exceptions.FlowRunCancelledException
+ assert "FlowRunFailedException" in TSC.__all__
+ assert "FlowRunCancelledException" in TSC.__all__
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_project.py b/test/test_project.py
index eb33f6732..839838101 100644
--- a/test/test_project.py
+++ b/test/test_project.py
@@ -1,4 +1,5 @@
from pathlib import Path
+from urllib.parse import parse_qs, urlparse
import pytest
import requests_mock
@@ -22,6 +23,12 @@
UPDATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML = (
TEST_ASSET_DIR / "project_update_virtualconnection_default_permissions.xml"
)
+GET_BY_NAME_TOP_LEVEL_XML = TEST_ASSET_DIR / "project_get_by_name_top_level.xml"
+GET_BY_NAME_CHILD_XML = TEST_ASSET_DIR / "project_get_by_name_child.xml"
+GET_EMPTY_XML = TEST_ASSET_DIR / "project_get_empty.xml"
+GET_BY_NAME_AMBIGUOUS_ROOT_XML = TEST_ASSET_DIR / "project_get_by_name_ambiguous_root.xml"
+GET_BY_NAME_DUPLICATE_SIBLINGS_XML = TEST_ASSET_DIR / "project_get_by_name_duplicate_siblings.xml"
+GET_BY_NAME_GRANDCHILD_XML = TEST_ASSET_DIR / "project_get_by_name_grandchild.xml"
@pytest.fixture(scope="function")
@@ -464,3 +471,229 @@ def test_get_all_fields(server: TSC.Server) -> None:
assert project.content_permissions == "ManagedByOwner"
assert project.parent_id is None
assert project.writeable is True
+
+
+# --- get_by_path tests ---
+
+
+def test_get_by_path_top_level(server: TSC.Server) -> None:
+ """A single-component path resolves to a top-level project."""
+ response_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
+ with requests_mock.mock() as m:
+ m.get(server.projects.baseurl + "?filter=name:eq:Tableau", text=response_xml)
+ project = server.projects.get_by_path("Tableau")
+
+ assert project is not None
+ assert project.id == "1d0304cd-3796-429f-b815-7258370b9b74"
+ assert project.name == "Tableau"
+ assert project.parent_id is None
+
+
+def test_get_by_path_top_level_with_leading_slash(server: TSC.Server) -> None:
+ """Leading slash is stripped; result is the same as without it."""
+ response_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
+ with requests_mock.mock() as m:
+ m.get(server.projects.baseurl + "?filter=name:eq:Tableau", text=response_xml)
+ project = server.projects.get_by_path("/Tableau")
+
+ assert project is not None
+ assert project.id == "1d0304cd-3796-429f-b815-7258370b9b74"
+
+
+def _filter_params(request) -> dict:
+ """Parse the comma-separated 'filter' query param into a dict of field:value pairs."""
+ qs = parse_qs(urlparse(request.url).query)
+ filters = {}
+ for token in qs.get("filter", [""])[0].split(","):
+ if ":" in token:
+ parts = token.split(":", 2) # field, operator, value
+ if len(parts) == 3:
+ filters[parts[0]] = parts[2]
+ return filters
+
+
+def test_get_by_path_nested(server: TSC.Server) -> None:
+ """A two-component path walks the hierarchy and returns the child project."""
+ top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
+ child_xml = GET_BY_NAME_CHILD_XML.read_text()
+ baseurl = server.projects.baseurl
+
+ def respond(request, context):
+ params = _filter_params(request)
+ if params.get("name") == "Tableau" and "parentProjectId" not in params:
+ return top_level_xml
+ if params.get("name") == "Child 1" and params.get("parentProjectId") == "1d0304cd-3796-429f-b815-7258370b9b74":
+ return child_xml
+ context.status_code = 404
+ return ""
+
+ with requests_mock.mock() as m:
+ m.get(baseurl, text=respond)
+ project = server.projects.get_by_path("Tableau/Child 1")
+
+ assert project is not None
+ assert project.id == "4cc52973-5e3a-4d1f-a4fb-5b5f73796edf"
+ assert project.name == "Child 1"
+ assert project.parent_id == "1d0304cd-3796-429f-b815-7258370b9b74"
+
+
+def test_get_by_path_not_found_root(server: TSC.Server) -> None:
+ """Returns None when the root-level component does not exist."""
+ empty_xml = GET_EMPTY_XML.read_text()
+ baseurl = server.projects.baseurl
+
+ def respond(request, context):
+ params = _filter_params(request)
+ if params.get("name") == "NonExistent":
+ return empty_xml
+ context.status_code = 404
+ return ""
+
+ with requests_mock.mock() as m:
+ m.get(baseurl, text=respond)
+ project = server.projects.get_by_path("NonExistent")
+
+ assert project is None
+
+
+def test_get_by_path_not_found_child(server: TSC.Server) -> None:
+ """Returns None when a child component does not exist under the parent."""
+ top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
+ empty_xml = GET_EMPTY_XML.read_text()
+ baseurl = server.projects.baseurl
+
+ def respond(request, context):
+ params = _filter_params(request)
+ if params.get("name") == "Tableau" and "parentProjectId" not in params:
+ return top_level_xml
+ if params.get("name") == "NoSuchChild":
+ return empty_xml
+ context.status_code = 404
+ return ""
+
+ with requests_mock.mock() as m:
+ m.get(baseurl, text=respond)
+ project = server.projects.get_by_path("Tableau/NoSuchChild")
+
+ assert project is None
+
+
+def test_get_by_path_empty_raises(server: TSC.Server) -> None:
+ """An empty or slash-only path raises ValueError."""
+ with pytest.raises(ValueError):
+ server.projects.get_by_path("")
+
+ with pytest.raises(ValueError):
+ server.projects.get_by_path("/")
+
+
+def test_get_by_path_trailing_slash(server: TSC.Server) -> None:
+ """Trailing slash is stripped; result is the same as the bare name."""
+ response_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
+ with requests_mock.mock() as m:
+ m.get(server.projects.baseurl + "?filter=name:eq:Tableau", text=response_xml)
+ project = server.projects.get_by_path("Tableau/")
+
+ assert project is not None
+ assert project.id == "1d0304cd-3796-429f-b815-7258370b9b74"
+
+
+def test_get_by_path_root_filters_non_top_level(server: TSC.Server) -> None:
+ """When the API returns projects with the same name at different levels,
+ get_by_path keeps only the one with no parent when looking at the root."""
+ ambiguous_xml = GET_BY_NAME_AMBIGUOUS_ROOT_XML.read_text()
+ baseurl = server.projects.baseurl
+
+ def respond(request, context):
+ params = _filter_params(request)
+ if params.get("name") == "Shared" and "parentProjectId" not in params:
+ return ambiguous_xml
+ context.status_code = 404
+ return ""
+
+ with requests_mock.mock() as m:
+ m.get(baseurl, text=respond)
+ project = server.projects.get_by_path("Shared")
+
+ # The implementation filters to parent_id is None at the root level, so
+ # only "bbbbbbbb-..." (the actual top-level project) should be returned.
+ assert project is not None
+ assert project.id == "bbbbbbbb-0000-0000-0000-000000000002"
+ assert project.parent_id is None
+
+
+def test_get_by_path_duplicate_siblings_returns_first(server: TSC.Server) -> None:
+ """When multiple sibling projects share the same name, the first one is returned."""
+ top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
+ duplicate_xml = GET_BY_NAME_DUPLICATE_SIBLINGS_XML.read_text()
+ baseurl = server.projects.baseurl
+
+ def respond(request, context):
+ params = _filter_params(request)
+ if params.get("name") == "Tableau" and "parentProjectId" not in params:
+ return top_level_xml
+ if params.get("name") == "Reports" and params.get("parentProjectId") == "1d0304cd-3796-429f-b815-7258370b9b74":
+ return duplicate_xml
+ context.status_code = 404
+ return ""
+
+ with requests_mock.mock() as m:
+ m.get(baseurl, text=respond)
+ project = server.projects.get_by_path("Tableau/Reports")
+
+ # Duplicate siblings: implementation takes the first result from the API.
+ assert project is not None
+ assert project.id == "cccccccc-0000-0000-0000-000000000001"
+
+
+def test_get_by_path_deep_three_levels(server: TSC.Server) -> None:
+ """A three-component path issues three requests and returns the deepest project."""
+ top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
+ child_xml = GET_BY_NAME_CHILD_XML.read_text()
+ grandchild_xml = GET_BY_NAME_GRANDCHILD_XML.read_text()
+ baseurl = server.projects.baseurl
+
+ def respond(request, context):
+ params = _filter_params(request)
+ if params.get("name") == "Tableau" and "parentProjectId" not in params:
+ return top_level_xml
+ if params.get("name") == "Child 1" and params.get("parentProjectId") == "1d0304cd-3796-429f-b815-7258370b9b74":
+ return child_xml
+ if params.get("name") == "Q1" and params.get("parentProjectId") == "4cc52973-5e3a-4d1f-a4fb-5b5f73796edf":
+ return grandchild_xml
+ context.status_code = 404
+ return ""
+
+ with requests_mock.mock() as m:
+ m.get(baseurl, text=respond)
+ project = server.projects.get_by_path("Tableau/Child 1/Q1")
+
+ assert project is not None
+ assert project.id == "eeeeeeee-0000-0000-0000-000000000001"
+ assert project.name == "Q1"
+ assert project.parent_id == "4cc52973-5e3a-4d1f-a4fb-5b5f73796edf"
+
+
+def test_get_by_path_with_spaces_in_name(server: TSC.Server) -> None:
+ """Project names containing spaces are handled correctly."""
+ # Reuses the child fixture whose name is 'Child 1' (contains a space).
+ top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
+ child_xml = GET_BY_NAME_CHILD_XML.read_text()
+ baseurl = server.projects.baseurl
+
+ def respond(request, context):
+ params = _filter_params(request)
+ if params.get("name") == "Tableau" and "parentProjectId" not in params:
+ return top_level_xml
+ if params.get("name") == "Child 1" and params.get("parentProjectId") == "1d0304cd-3796-429f-b815-7258370b9b74":
+ return child_xml
+ context.status_code = 404
+ return ""
+
+ with requests_mock.mock() as m:
+ m.get(baseurl, text=respond)
+ project = server.projects.get_by_path("Tableau/Child 1")
+
+ assert project is not None
+ assert project.id == "4cc52973-5e3a-4d1f-a4fb-5b5f73796edf"
+ assert project.name == "Child 1"
diff --git a/test/test_redirect_handling.py b/test/test_redirect_handling.py
new file mode 100644
index 000000000..37bca5c18
--- /dev/null
+++ b/test/test_redirect_handling.py
@@ -0,0 +1,355 @@
+"""Tests for manual redirect handling in Endpoint._make_request.
+
+`requests` follows 301/302/303 by converting POST to GET (dropping the body).
+We disable auto-redirect and re-issue the same method ourselves inside
+`Endpoint._make_request`. These tests drive real endpoint calls (sign_in,
+workbooks.get, etc.) through a `requests_mock` transport, so they exercise
+the same code path production traffic takes -- not the helper in isolation.
+"""
+
+from pathlib import Path
+
+import pytest
+import requests_mock
+
+import tableauserverclient as TSC
+from tableauserverclient.server.endpoint.exceptions import RedirectError
+
+TEST_ASSET_DIR = Path(__file__).parent / "assets"
+SIGN_IN_XML = TEST_ASSET_DIR / "auth_sign_in.xml"
+GET_XML = TEST_ASSET_DIR / "workbook_get.xml"
+
+
+@pytest.fixture
+def server() -> TSC.Server:
+ s = TSC.Server("http://test", False)
+ return s
+
+
+@pytest.fixture
+def signed_in_server() -> TSC.Server:
+ s = TSC.Server("http://test", False)
+ s.version = "3.10"
+ s._set_auth("site-id", "user-id", "auth-token", "")
+ return s
+
+
+def _sign_in_xml() -> str:
+ return SIGN_IN_XML.read_text()
+
+
+def _workbooks_get_xml() -> str:
+ return GET_XML.read_text()
+
+
+# --- Body / method preservation ---------------------------------------------
+
+
+def test_post_body_preserved_across_redirect(server: TSC.Server) -> None:
+ # Regression for tableau/tabcmd#309: POST -> 302 previously turned into GET
+ # and dropped the body. Sign-in is the load-bearing POST path; drive it
+ # end-to-end and verify (a) the body reaches the final URL intact, and
+ # (b) sign_in still parses the response and sets auth state.
+ xml = _sign_in_xml()
+ seen_bodies: list[bytes | None] = []
+
+ def record_final(request, context):
+ seen_bodies.append(request.body)
+ context.status_code = 200
+ return xml
+
+ with requests_mock.mock() as m:
+ m.post(
+ server.auth.baseurl + "/signin",
+ status_code=302,
+ headers={"Location": "http://test/api/3.6/auth/signin"},
+ )
+ m.post("http://test/api/3.6/auth/signin", text=record_final)
+
+ tableau_auth = TSC.TableauAuth("u", "p", site_id="Samples")
+ server.auth.sign_in(tableau_auth)
+
+ assert server.auth_token is not None, "sign_in did not complete"
+ assert len(seen_bodies) == 1
+ assert seen_bodies[0] is not None
+ assert b" None:
+ xml = _sign_in_xml()
+ with requests_mock.mock() as m:
+ m.post(server.auth.baseurl + "/signin", status_code=301, headers={"Location": "http://test/b"})
+ m.post("http://test/b", status_code=302, headers={"Location": "http://test/c"})
+ m.post("http://test/c", status_code=303, headers={"Location": "http://test/d"})
+ m.post("http://test/d", text=xml)
+
+ server.auth.sign_in(TSC.TableauAuth("u", "p"))
+
+ assert server.auth_token is not None
+
+
+def test_headers_survive_redirect(signed_in_server: TSC.Server) -> None:
+ # Regression: the whole point of the PR is method+body+*headers*
+ # preservation. Verify X-Tableau-Auth reaches the redirect target.
+ seen_headers: list[dict] = []
+
+ def capture(request, context):
+ seen_headers.append(dict(request.headers))
+ context.status_code = 200
+ return _workbooks_get_xml()
+
+ baseurl = signed_in_server.workbooks.baseurl
+ with requests_mock.mock() as m:
+ m.get(baseurl, status_code=302, headers={"Location": baseurl + "?redirected=1"})
+ m.get(baseurl + "?redirected=1", text=capture)
+ signed_in_server.workbooks.get()
+
+ # Last hop was the terminal 200 -- inspect its headers.
+ assert seen_headers, "final GET never fired"
+ final = seen_headers[-1]
+ assert final.get("x-tableau-auth") == "auth-token" or final.get("X-Tableau-Auth") == "auth-token", final
+
+
+def test_get_redirect_still_works(signed_in_server: TSC.Server) -> None:
+ baseurl = signed_in_server.workbooks.baseurl
+ with requests_mock.mock() as m:
+ m.get(baseurl, status_code=301, headers={"Location": baseurl + "?v=2"})
+ m.get(baseurl + "?v=2", text=_workbooks_get_xml())
+ result = signed_in_server.workbooks.get()
+ assert result[0] is not None
+
+
+# --- Redirect codes ---------------------------------------------------------
+
+
+def test_response_history_populated_across_multi_hop_chain(server: TSC.Server) -> None:
+ # Regression: `requests` normally populates `response.history` with the
+ # intermediate 3xx responses when it follows a redirect chain. TSC's
+ # manual walker took over that job, so we have to populate .history
+ # ourselves for callers doing forensic debugging.
+ from unittest.mock import patch
+
+ xml = _sign_in_xml()
+ captured: list = []
+ original_check = TSC.server.endpoint.Endpoint._check_status # type: ignore[attr-defined]
+
+ def capture(self, response, url=None):
+ captured.append(response)
+ return original_check(self, response, url)
+
+ with requests_mock.mock() as m:
+ m.post(server.auth.baseurl + "/signin", status_code=301, headers={"Location": "http://test/b"})
+ m.post("http://test/b", status_code=302, headers={"Location": "http://test/c"})
+ m.post("http://test/c", text=xml)
+ with patch.object(TSC.server.endpoint.Endpoint, "_check_status", capture): # type: ignore[attr-defined]
+ server.auth.sign_in(TSC.TableauAuth("u", "p"))
+
+ assert len(captured) == 1, f"Expected one final response, got {len(captured)}"
+ final = captured[0]
+ assert final.status_code == 200
+ assert len(final.history) == 2, f"Expected 2 intermediate hops in history, got {len(final.history)}"
+ assert final.history[0].status_code == 301
+ assert final.history[1].status_code == 302
+
+
+def test_response_history_empty_when_no_redirect(signed_in_server: TSC.Server) -> None:
+ # When there's no redirect, .history stays as whatever requests set it
+ # (empty list). We don't overwrite it in the no-redirect short-circuit.
+ from unittest.mock import patch
+
+ captured: list = []
+ original_check = TSC.server.endpoint.Endpoint._check_status # type: ignore[attr-defined]
+
+ def capture(self, response, url=None):
+ captured.append(response)
+ return original_check(self, response, url)
+
+ baseurl = signed_in_server.workbooks.baseurl
+ with requests_mock.mock() as m:
+ m.get(baseurl, text=_workbooks_get_xml())
+ with patch.object(TSC.server.endpoint.Endpoint, "_check_status", capture): # type: ignore[attr-defined]
+ signed_in_server.workbooks.get()
+
+ assert len(captured) == 1
+ assert captured[0].status_code == 200
+ assert captured[0].history == []
+
+
+@pytest.mark.parametrize("code", [301, 302, 303, 307, 308])
+def test_all_supported_redirect_codes_preserve_post_body(server: TSC.Server, code: int) -> None:
+ xml = _sign_in_xml()
+ seen_bodies: list[bytes | None] = []
+
+ def capture(request, context):
+ seen_bodies.append(request.body)
+ context.status_code = 200
+ return xml
+
+ with requests_mock.mock() as m:
+ m.post(server.auth.baseurl + "/signin", status_code=code, headers={"Location": "http://test/new"})
+ m.post("http://test/new", text=capture)
+ server.auth.sign_in(TSC.TableauAuth("u", "p"))
+
+ assert len(seen_bodies) == 1
+ assert seen_bodies[0] is not None
+ assert b" None:
+ # Only 301/302/303/307/308 are in Redirect_codes. Others should reach
+ # _check_status unchanged and surface as ServerResponseError or similar.
+ baseurl = signed_in_server.workbooks.baseurl
+ with requests_mock.mock() as m:
+ m.get(
+ baseurl,
+ status_code=code,
+ text="xy",
+ )
+ with pytest.raises(TSC.ServerResponseError):
+ signed_in_server.workbooks.get()
+
+
+# --- Scheme handling --------------------------------------------------------
+
+
+def test_https_to_http_downgrade_rejected() -> None:
+ s = TSC.Server("https://secure.test", False)
+ with requests_mock.mock() as m:
+ m.post(
+ s.auth.baseurl + "/signin",
+ status_code=301,
+ headers={"Location": "http://insecure.test/api/3.6/auth/signin"},
+ )
+ with pytest.raises(RedirectError, match="HTTPS -> HTTP"):
+ s.auth.sign_in(TSC.TableauAuth("u", "p"))
+
+
+def test_https_to_http_downgrade_rejected_on_later_hop() -> None:
+ # First hop is https->https (safe), second hop tries to downgrade.
+ # Regression coverage that the guard runs each iteration, not just once.
+ s = TSC.Server("https://a.test", False)
+ with requests_mock.mock() as m:
+ m.post(s.auth.baseurl + "/signin", status_code=301, headers={"Location": "https://b.test/signin"})
+ m.post("https://b.test/signin", status_code=301, headers={"Location": "http://c.test/signin"})
+ with pytest.raises(RedirectError, match="HTTPS -> HTTP"):
+ s.auth.sign_in(TSC.TableauAuth("u", "p"))
+
+
+def test_http_to_https_upgrade_allowed(server: TSC.Server) -> None:
+ # Not a security concern -- the whole point of #309 is that
+ # http://.../signin -> https://.../signin should work.
+ xml = _sign_in_xml()
+ with requests_mock.mock() as m:
+ m.post(
+ server.auth.baseurl + "/signin", status_code=301, headers={"Location": "https://test/api/3.6/auth/signin"}
+ )
+ m.post("https://test/api/3.6/auth/signin", text=xml)
+ server.auth.sign_in(TSC.TableauAuth("u", "p"))
+ assert server.auth_token is not None
+
+
+def test_http_to_https_upgrade_promotes_stored_server_address(server: TSC.Server) -> None:
+ # When the server redirects http://host -> https://host on the same host,
+ # promote server._server_address so subsequent requests skip the redirect.
+ assert server._server_address == "http://test"
+ xml = _sign_in_xml()
+ with requests_mock.mock() as m:
+ m.post(
+ server.auth.baseurl + "/signin", status_code=301, headers={"Location": "https://test/api/3.6/auth/signin"}
+ )
+ m.post("https://test/api/3.6/auth/signin", text=xml)
+ server.auth.sign_in(TSC.TableauAuth("u", "p"))
+ assert server._server_address == "https://test"
+
+
+def test_http_to_https_upgrade_does_not_promote_on_different_host(server: TSC.Server) -> None:
+ # If the redirect target is on a different host, do NOT rewrite the stored
+ # server address -- the redirect might be to a completely unrelated server
+ # and rewriting would silently point every future call at it.
+ assert server._server_address == "http://test"
+ xml = _sign_in_xml()
+ with requests_mock.mock() as m:
+ m.post(
+ server.auth.baseurl + "/signin",
+ status_code=301,
+ headers={"Location": "https://other-host/api/3.6/auth/signin"},
+ )
+ m.post("https://other-host/api/3.6/auth/signin", text=xml)
+ server.auth.sign_in(TSC.TableauAuth("u", "p"))
+ assert server._server_address == "http://test"
+
+
+def test_cross_host_redirect_followed(server: TSC.Server) -> None:
+ # e.g. http://online.tableau.com -> http://east.online.tableau.com.
+ # This is the scenario in the original inline signin comment.
+ xml = _sign_in_xml()
+ with requests_mock.mock() as m:
+ m.post(
+ server.auth.baseurl + "/signin",
+ status_code=301,
+ headers={"Location": "http://east.test/api/3.6/auth/signin"},
+ )
+ m.post("http://east.test/api/3.6/auth/signin", text=xml)
+ server.auth.sign_in(TSC.TableauAuth("u", "p"))
+ assert server.auth_token is not None
+
+
+# --- Location edge cases ----------------------------------------------------
+
+
+def test_relative_location_header(server: TSC.Server) -> None:
+ # RFC 7231 allows relative Location values; urljoin against request URL.
+ xml = _sign_in_xml()
+ with requests_mock.mock() as m:
+ m.post(server.auth.baseurl + "/signin", status_code=302, headers={"Location": "/api/3.6/auth/signin"})
+ m.post("http://test/api/3.6/auth/signin", text=xml)
+ server.auth.sign_in(TSC.TableauAuth("u", "p"))
+ assert server.auth_token is not None
+
+
+def test_missing_location_header_raises_redirecterror(server: TSC.Server) -> None:
+ # `requests`' internal resolve_redirects raises KeyError('location') with no
+ # context. We raise RedirectError with URL, method, status code.
+ with requests_mock.mock() as m:
+ m.post(server.auth.baseurl + "/signin", status_code=302) # no Location header
+ with pytest.raises(RedirectError, match="without a Location header"):
+ server.auth.sign_in(TSC.TableauAuth("u", "p"))
+
+
+# --- Hop limits -------------------------------------------------------------
+
+
+def test_redirect_loop_hits_max_hops(server: TSC.Server) -> None:
+ server.session.max_redirects = 3
+ with requests_mock.mock() as m:
+ m.post(server.auth.baseurl + "/signin", status_code=302, headers={"Location": "http://test/loop"})
+ m.post("http://test/loop", status_code=302, headers={"Location": "http://test/loop"})
+ with pytest.raises(RedirectError, match="Exceeded 3 redirect hops"):
+ server.auth.sign_in(TSC.TableauAuth("u", "p"))
+
+
+def test_max_redirects_zero_passes_non_redirect_response(signed_in_server: TSC.Server) -> None:
+ # Regression for the review finding: with the loop bounded by
+ # `range(max_hops)`, max_redirects=0 previously fell straight into the
+ # "exceeded" error even for a 200 response.
+ signed_in_server.session.max_redirects = 0
+ baseurl = signed_in_server.workbooks.baseurl
+ with requests_mock.mock() as m:
+ m.get(baseurl, text=_workbooks_get_xml())
+ result = signed_in_server.workbooks.get()
+ assert result[0] is not None
+
+
+def test_max_redirects_one_rejects_second_hop(signed_in_server: TSC.Server) -> None:
+ # max_redirects=1 allows one non-redirect response but errors on a second
+ # 3xx. (max_redirects=0 is not tested because `requests` itself refuses to
+ # complete any request that returns 3xx when max_redirects=0, regardless of
+ # allow_redirects; the response never reaches _make_request.)
+ signed_in_server.session.max_redirects = 1
+ baseurl = signed_in_server.workbooks.baseurl
+ with requests_mock.mock() as m:
+ m.get(baseurl, status_code=302, headers={"Location": baseurl + "?v=2"})
+ m.get(baseurl + "?v=2", status_code=302, headers={"Location": baseurl + "?v=3"})
+ with pytest.raises(RedirectError, match="Exceeded 1 redirect hops"):
+ signed_in_server.workbooks.get()
diff --git a/test/test_site.py b/test/test_site.py
index e976bc1d2..3e15330ff 100644
--- a/test/test_site.py
+++ b/test/test_site.py
@@ -205,6 +205,34 @@ def test_update_missing_id(server: TSC.Server) -> None:
server.sites.update(single_site)
+def test_update_subscription_email_and_footer_preserve_case() -> None:
+ # Regression for #1849: RequestFactory used to .lower() customSubscriptionEmail
+ # and customSubscriptionFooter, silently mangling caller intent. Footer
+ # especially, since it is displayed verbatim in outgoing subscription emails.
+ site = TSC.SiteItem(name="X", content_url="x")
+ site.custom_subscription_email = "Sales@Company.com"
+ site.custom_subscription_footer = "Sent by Tableau -- Confidential. See https://Example.com/Legal"
+
+ site_elem = ET.fromstring(RequestFactory.Site.update_req(site)).find(".//site")
+ assert site_elem is not None
+ assert site_elem.attrib["customSubscriptionEmail"] == "Sales@Company.com"
+ assert site_elem.attrib["customSubscriptionFooter"] == (
+ "Sent by Tableau -- Confidential. See https://Example.com/Legal"
+ )
+
+
+def test_create_subscription_email_and_footer_preserve_case() -> None:
+ # Same regression for the create-site path.
+ site = TSC.SiteItem(name="X", content_url="x")
+ site.custom_subscription_email = "Support@Company.com"
+ site.custom_subscription_footer = "COMPANY, Inc. -- All Rights Reserved."
+
+ site_elem = ET.fromstring(RequestFactory.Site.create_req(site)).find(".//site")
+ assert site_elem is not None
+ assert site_elem.attrib["customSubscriptionEmail"] == "Support@Company.com"
+ assert site_elem.attrib["customSubscriptionFooter"] == "COMPANY, Inc. -- All Rights Reserved."
+
+
def test_null_site_quota(server: TSC.Server) -> None:
test_site = TSC.SiteItem("testname", "testcontenturl", tier_explorer_capacity=1, user_quota=None)
assert test_site.tier_explorer_capacity == 1
diff --git a/test/test_thread_safety.py b/test/test_thread_safety.py
new file mode 100644
index 000000000..378d500e1
--- /dev/null
+++ b/test/test_thread_safety.py
@@ -0,0 +1,292 @@
+import threading
+from concurrent.futures import ThreadPoolExecutor
+from pathlib import Path
+
+import pytest
+import requests
+import requests_mock
+
+import tableauserverclient as TSC
+
+TEST_ASSET_DIR = Path(__file__).parent / "assets"
+
+SIGN_IN_XML = TEST_ASSET_DIR / "auth_sign_in.xml"
+
+NUM_THREADS = 8
+CALLS_PER_THREAD = 5
+
+
+@pytest.fixture(scope="function")
+def server() -> TSC.Server:
+ return TSC.Server("http://test", False)
+
+
+@pytest.fixture(scope="function")
+def signed_in_server(server: TSC.Server) -> TSC.Server:
+ with open(SIGN_IN_XML, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+ with requests_mock.mock() as m:
+ m.post(server.auth.baseurl + "/signin", text=response_xml)
+ server.auth.sign_in(TSC.TableauAuth("testuser", "password", site_id="Samples"))
+ return server
+
+
+def test_each_thread_gets_its_own_session(server: TSC.Server) -> None:
+ main_session = server.session
+ assert server.session is main_session # stable within a thread
+
+ # Hold strong references to the session objects (not just id()s, which can
+ # be reused once a thread's session is garbage collected) and keep all
+ # threads alive at the same time behind a barrier.
+ barrier = threading.Barrier(NUM_THREADS)
+ results: dict[str, requests.Session] = {}
+ lock = threading.Lock()
+
+ def worker(name: str) -> None:
+ barrier.wait()
+ first = server.session
+ assert server.session is first # stable within the worker thread too
+ with lock:
+ results[name] = first
+
+ threads = [threading.Thread(target=worker, args=(f"t{i}",)) for i in range(NUM_THREADS)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+
+ sessions = list(results.values()) + [main_session]
+ # every thread saw a distinct session object
+ assert len({id(s) for s in sessions}) == NUM_THREADS + 1
+
+
+def test_session_factory_called_once_per_thread() -> None:
+ lock = threading.Lock()
+ created: list[requests.Session] = []
+
+ def counting_factory() -> requests.Session:
+ session = requests.Session()
+ with lock:
+ created.append(session)
+ return session
+
+ server = TSC.Server("http://test", False, session_factory=counting_factory)
+ # constructing the server creates the constructing thread's session
+ assert len(created) == 1
+
+ barrier = threading.Barrier(NUM_THREADS)
+
+ def worker() -> None:
+ barrier.wait()
+ for _ in range(CALLS_PER_THREAD):
+ server.session # repeated access must not create new sessions
+
+ threads = [threading.Thread(target=worker) for _ in range(NUM_THREADS)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+
+ # one factory call for the main thread + exactly one per worker thread,
+ # despite CALLS_PER_THREAD accesses in each worker
+ assert len(created) == NUM_THREADS + 1
+
+
+def test_sign_out_invalidates_sessions_of_all_threads(signed_in_server: TSC.Server) -> None:
+ server = signed_in_server
+ before_main = server.session
+
+ barrier = threading.Barrier(2)
+ worker_sessions: dict[str, int] = {}
+
+ def worker() -> None:
+ worker_sessions["before"] = id(server.session)
+ barrier.wait() # let the main thread sign out
+ barrier.wait()
+ worker_sessions["after"] = id(server.session)
+
+ t = threading.Thread(target=worker)
+ t.start()
+ barrier.wait() # worker has cached its session
+ with requests_mock.mock() as m:
+ m.post(server.auth.baseurl + "/signout", text="")
+ server.auth.sign_out()
+ barrier.wait() # worker re-reads its session
+ t.join()
+
+ # both the main thread and the worker thread got fresh sessions
+ assert server.session is not before_main
+ assert worker_sessions["after"] != worker_sessions["before"]
+ assert not server.is_signed_in()
+
+
+def test_concurrent_api_calls_use_per_thread_sessions(signed_in_server: TSC.Server) -> None:
+ server = signed_in_server
+ response_xml = (TEST_ASSET_DIR / "user_get_empty.xml").read_text()
+
+ with requests_mock.mock() as m:
+ m.get(server.users.baseurl, text=response_xml)
+
+ def worker() -> int:
+ for _ in range(CALLS_PER_THREAD):
+ _, pagination_item = server.users.get()
+ assert pagination_item.total_available == 0
+ return id(server.session)
+
+ with ThreadPoolExecutor(max_workers=NUM_THREADS) as executor:
+ session_ids = list(executor.map(lambda _: worker(), range(NUM_THREADS)))
+
+ # every request was actually made
+ assert m.call_count == NUM_THREADS * CALLS_PER_THREAD
+ # every request carried the shared auth token
+ assert all(r.headers["x-tableau-auth"] == server.auth_token for r in m.request_history)
+
+ # the pool had NUM_THREADS workers; each distinct worker thread used a
+ # distinct session, and threads reused their session across tasks
+ assert 1 <= len(set(session_ids)) <= NUM_THREADS
+
+
+def test_auth_state_is_set_atomically(server: TSC.Server) -> None:
+ """Readers must never observe a half-updated (site_id, user_id, token) state.
+
+ The reader takes ONE snapshot of the immutable auth state and asserts
+ consistency within it. Asserting across two separate property reads
+ (server.auth_token then server.site_id) would be a bug in the test: a
+ writer can legitimately complete a full swap between the two reads,
+ which free-threaded (no-GIL) builds surface readily. Individual
+ property reads are each internally consistent; only the snapshot
+ guarantees a consistent multi-field view.
+ """
+ stop = threading.Event()
+ errors: list[Exception] = []
+
+ def reader() -> None:
+ while not stop.is_set():
+ try:
+ state = server._auth_state
+ if state.auth_token is not None:
+ # all fields come from the same snapshot; a mismatched
+ # pair means a writer published a partial update
+ assert (state.auth_token, state.site_id, state.user_id) in (
+ ("token-a", "site-a", "user-a"),
+ ("token-b", "site-b", "user-b"),
+ )
+ # single property reads must be internally consistent too:
+ # either a value or NotSignedInError, never None
+ try:
+ assert server.auth_token is not None
+ except TSC.server.endpoint.exceptions.NotSignedInError:
+ pass # signed out at the moment of the read; that's fine
+ except Exception as e: # pragma: no cover - only on failure
+ errors.append(e)
+ stop.set()
+
+ readers = [threading.Thread(target=reader) for _ in range(4)]
+ for t in readers:
+ t.start()
+ try:
+ for _ in range(500):
+ server._set_auth("site-a", "user-a", "token-a", "url-a")
+ server._set_auth("site-b", "user-b", "token-b", "url-b")
+ server._clear_auth()
+ finally:
+ stop.set()
+ for t in readers:
+ t.join()
+
+ assert errors == []
+
+
+class _TrackingSession(requests.Session):
+ def __init__(self) -> None:
+ super().__init__()
+ self.closed = False
+
+ def close(self) -> None:
+ self.closed = True
+ super().close()
+
+
+def _tracking_server() -> tuple[TSC.Server, list["_TrackingSession"]]:
+ created: list[_TrackingSession] = []
+ lock = threading.Lock()
+
+ def factory() -> requests.Session:
+ session = _TrackingSession()
+ with lock:
+ created.append(session)
+ return session
+
+ return TSC.Server("http://test", False, session_factory=factory), created
+
+
+def test_close_closes_sessions_of_all_threads() -> None:
+ server, created = _tracking_server()
+ barrier = threading.Barrier(NUM_THREADS)
+
+ def worker() -> None:
+ barrier.wait()
+ server.session
+
+ threads = [threading.Thread(target=worker) for _ in range(NUM_THREADS)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+
+ assert len(created) == NUM_THREADS + 1 # workers + constructing thread
+ assert not any(s.closed for s in created)
+
+ server.close()
+ assert all(s.closed for s in created)
+
+ # the server remains usable after close: a fresh session is created
+ reopened = server.session
+ assert isinstance(reopened, _TrackingSession)
+ assert not reopened.closed
+ assert reopened not in created[: NUM_THREADS + 1] or len(created) == NUM_THREADS + 2
+
+
+def test_close_does_not_sign_out(signed_in_server: TSC.Server) -> None:
+ server = signed_in_server
+ token = server.auth_token
+ server.close()
+ # close() is transport-level only; auth state is untouched
+ assert server.is_signed_in()
+ assert server.auth_token == token
+
+
+def test_context_manager_closes_on_exit() -> None:
+ server, created = _tracking_server()
+ with server as entered:
+ assert entered is server
+ server.session
+ assert len(created) == 1
+ assert all(s.closed for s in created)
+
+
+def test_private_session_attribute_shim() -> None:
+ # backwards compatibility: external code reads and assigns
+ # Server._session (the pre-thread-safety attribute)
+ server, created = _tracking_server()
+ assert server._session is server.session # alias for this thread's session
+
+ injected = _TrackingSession()
+ server._session = injected
+ assert server.session is injected # this thread now uses the injection
+
+ # injection is per-thread: another thread still gets a factory session
+ other: dict[str, requests.Session] = {}
+
+ def worker() -> None:
+ other["session"] = server.session
+
+ t = threading.Thread(target=worker)
+ t.start()
+ t.join()
+ assert other["session"] is not injected
+ assert isinstance(other["session"], _TrackingSession)
+
+ # close() reaches the injected session too
+ server.close()
+ assert injected.closed
diff --git a/test/test_user.py b/test/test_user.py
index fec21d25f..841233e38 100644
--- a/test/test_user.py
+++ b/test/test_user.py
@@ -405,7 +405,7 @@ def test_create_users_csv() -> None:
"ServerAdministrator": "System",
}
- csv_columns = ["name", "password", "fullname", "license", "admin", "publish", "email"]
+ csv_columns = ["name", "password", "fullname", "license", "admin", "publish", "email", "auth"]
csv_data = create_users_csv(users)
csv_file = io.StringIO(csv_data.decode("utf-8"))
csv_reader = csv.reader(csv_file)
@@ -417,8 +417,23 @@ def test_create_users_csv() -> None:
assert (user.fullname or "") == csv_user["fullname"]
assert (user.email or "") == csv_user["email"]
assert license_map[site_role] == csv_user["license"]
- assert admin_map.get(site_role, "") == csv_user["admin"]
+ assert admin_map.get(site_role, "None") == csv_user["admin"]
assert publish_map[site_role] == int(csv_user["publish"])
+ assert (user.auth_setting or "") == csv_user["auth"]
+
+
+def test_decompose_unsupported_role_emits_invalid_license() -> None:
+ # UnlicensedWithPublish and ViewerWithPublish are in UserItem.Roles for
+ # historical reasons but the server-side CSV license parser has never
+ # accepted them. _decompose_site_role emits license="Invalid" for these
+ # (and any other unmapped role) so the server rejects the row with
+ # USER_CSV_INVALID_LICENSE, preserving the per-row error semantics
+ # callers of bulk_add had before this refactor.
+ for role in ("UnlicensedWithPublish", "ViewerWithPublish", "Guest", "SupportUser"):
+ license, admin, publish = TSC.UserItem.CSVImport._decompose_site_role(role)
+ assert license == "Invalid"
+ assert admin == "None"
+ assert publish == "0"
def test_bulk_add(server: TSC.Server) -> None:
@@ -598,3 +613,24 @@ def test_remove_users_csv(server: TSC.Server) -> None:
assert (
name == f"{user.domain_name}\\{user.name}"
), f"Name in csv does not match expected name: {user.domain_name}\\{user.name}"
+
+
+def test_from_xml_accepts_unknown_auth_setting() -> None:
+ # Regression: response parsing must not enforce the UserItem.Auth enum. If a
+ # future Tableau release adds a new auth type, TSC's `_set_values` used to
+ # route through the @property_is_enum(Auth) setter, which raised ValueError
+ # on any value outside {SAML, OpenID, TableauIDWithMFA, ServerDefault},
+ # breaking response parsing entirely. The CSV import path validates upstream
+ # against CSVImport._AUTH_CANONICAL, so the enum guard on _set_values was
+ # redundant there and harmful on server-parse paths.
+ ns = {"t": "http://tableau.com/api"}
+ xml = ET.fromstring(
+ b""
+ )
+ item = TSC.UserItem.from_xml(xml, ns)
+ assert (
+ item.auth_setting == "FutureAuthType42"
+ ), "from_xml must preserve unknown auth values verbatim, not raise or drop them"
+ assert item.name == "alice"
+ assert item.site_role == "Viewer"
diff --git a/test/test_user_model.py b/test/test_user_model.py
index 49e8dc25c..ccb5cedc6 100644
--- a/test/test_user_model.py
+++ b/test/test_user_model.py
@@ -80,6 +80,39 @@ def test_evaluate_role() -> None:
assert actual == line[3], line + [actual]
+# _decompose_site_role writes CSV rows that the server (and TSC's own
+# _evaluate_site_role) parse back into a site role. This parametrized test
+# pins the round-trip so a change in either direction can't drift silently.
+# The two documented asymmetries are the ServerAdministrator/SiteAdministrator
+# label pair and the legacy-role fold; both are captured explicitly below.
+@pytest.mark.parametrize(
+ "role, expected",
+ [
+ # Canonical current-model roles round-trip identity.
+ ("SiteAdministratorCreator", "SiteAdministratorCreator"),
+ ("SiteAdministratorExplorer", "SiteAdministratorExplorer"),
+ ("Creator", "Creator"),
+ ("ExplorerCanPublish", "ExplorerCanPublish"),
+ ("Explorer", "Explorer"),
+ ("Viewer", "Viewer"),
+ ("Unlicensed", "Unlicensed"),
+ # admin="System" always evaluates back to the legacy "SiteAdministrator"
+ # label -- that's the only label _evaluate_site_role emits for System.
+ ("ServerAdministrator", "SiteAdministrator"),
+ # Legacy roles fold into their modern equivalents on the way through.
+ # Documented in _decompose_site_role's docstring.
+ ("SiteAdministrator", "SiteAdministratorExplorer"),
+ ("ReadOnly", "Viewer"),
+ ("Publisher", "ExplorerCanPublish"),
+ ("Interactor", "Explorer"),
+ ],
+)
+def test_decompose_then_evaluate_round_trips(role: str, expected: str) -> None:
+ license_level, admin_level, publish = TSC.UserItem.CSVImport._decompose_site_role(role)
+ actual = TSC.UserItem.CSVImport._evaluate_site_role(license_level, admin_level, publish)
+ assert actual == expected, (role, license_level, admin_level, publish, actual)
+
+
def test_get_user_detail_empty_line() -> None:
test_line = ""
test_user = TSC.UserItem.CSVImport.create_user_from_line(test_line)
@@ -136,3 +169,68 @@ def test_validate_usernames_file() -> None:
test_data = _mock_file_content(usernames)
valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, logger)
assert valid == 5, f"Exactly 5 of the lines were valid, counted {valid + len(invalid)}"
+
+
+def test_validate_mixed_case_license() -> None:
+ # Regression: issue #1809 - 'Viewer' (capital V) was rejected by case-sensitive check
+ TSC.UserItem.CSVImport._validate_import_line_or_throw("username, pword, fname, Viewer, None, no, email", logger)
+ TSC.UserItem.CSVImport._validate_import_line_or_throw("username, pword, fname, Creator, Site, yes, email", logger)
+ TSC.UserItem.CSVImport._validate_import_line_or_throw("username, pword, fname, EXPLORER, NONE, YES, email", logger)
+
+
+def test_validate_tableauid_with_mfa_auth() -> None:
+ # TableauIDWithMFA is a valid auth value and must not be rejected
+ TSC.UserItem.CSVImport._validate_import_line_or_throw(
+ "username, pword, fname, creator, none, yes, email, TableauIDWithMFA", logger
+ )
+
+
+def test_create_user_preserves_username_case() -> None:
+ # Username must not be lowercased - case matters for LDAP and email-format usernames
+ user = TSC.UserItem.CSVImport.create_user_from_line("JSmith, pword, John Smith, creator, none, yes, j@example.com")
+ assert user is not None
+ assert user.name == "JSmith", f"Username was lowercased: {user.name}"
+
+
+def test_create_user_with_auth_column() -> None:
+ # AUTH column (position 7) must be parsed - was broken by MAX=7 off-by-one
+ user = TSC.UserItem.CSVImport.create_user_from_line("username, pword, fname, creator, none, yes, email, SAML")
+ assert user is not None
+ assert user.auth_setting == "SAML", f"Expected SAML, got {user.auth_setting}"
+
+
+def test_too_many_columns_raises() -> None:
+ with pytest.raises(ValueError):
+ TSC.UserItem.CSVImport.create_user_from_line("u, p, n, creator, none, yes, email, SAML, extra")
+
+
+def test_create_user_with_unknown_auth_passes_through_with_warning() -> None:
+ # Unknown AUTH values pass through with a UserWarning rather than raising.
+ # TSC's _AUTH_CANONICAL is a hardcoded list that lags server-side auth-type
+ # additions; refusing would block CSV imports against newer servers as
+ # soon as Tableau ships a new auth type. If the value really is a typo,
+ # the server rejects the row when the request posts.
+ with pytest.warns(UserWarning, match="Unknown auth setting"):
+ user = TSC.UserItem.CSVImport.create_user_from_line(
+ "username, pword, fname, creator, none, yes, email, NotAnAuthType"
+ )
+ assert user is not None
+ assert user.auth_setting == "NotAnAuthType"
+
+
+def test_create_user_with_lowercase_auth_accepted() -> None:
+ # AUTH values are canonicalized case-insensitively - 'saml' should produce 'SAML'.
+ user = TSC.UserItem.CSVImport.create_user_from_line("username, pword, fname, creator, none, yes, email, saml")
+ assert user is not None
+ assert user.auth_setting == "SAML"
+
+
+def test_validate_import_line_warns_on_unknown_auth() -> None:
+ # _validate_import_line_or_throw matches create_user_from_line's warn-and-
+ # pass behavior on unknown auth values: the same row shouldn't be accepted
+ # by one path and rejected by the other.
+ with pytest.warns(UserWarning, match="Unknown auth setting"):
+ TSC.UserItem.CSVImport._validate_import_line_or_throw(
+ "username, pword, fname, creator, none, yes, email, NotAnAuthType",
+ logger,
+ )