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, + )