diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 000000000..16c8d1b9c
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,46 @@
+name: Build and publish docs
+
+on:
+ push:
+ branches: [master]
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ build-and-pr:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+
+ - name: Install docs dependencies
+ run: pip install -e ".[docs]"
+
+ - name: Build Sphinx docs
+ run: sphinx-build -b html docs sphinx_build
+
+ - name: Checkout gh-pages
+ uses: actions/checkout@v4
+ with:
+ ref: gh-pages
+ path: gh-pages
+
+ - name: Copy Sphinx output into gh-pages/sphinx
+ run: |
+ rm -rf gh-pages/sphinx
+ cp -r sphinx_build gh-pages/sphinx
+
+ - name: Create PR to gh-pages
+ uses: peter-evans/create-pull-request@v6
+ with:
+ path: gh-pages
+ branch: docs-update
+ base: gh-pages
+ title: "docs: update generated API reference"
+ body: "Automated update of Sphinx-generated API reference from master."
+ commit-message: "docs: regenerate Sphinx API reference"
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 000000000..96209fd5a
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,25 @@
+# Read the Docs configuration file
+# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
+
+# Required
+version: 2
+
+# Set the OS, Python version, and other tools you might need
+build:
+ os: ubuntu-24.04
+ tools:
+ python: "3.13"
+
+# Build documentation in the "docs/" directory with Sphinx
+sphinx:
+ configuration: docs/conf.py
+
+# Optionally, but recommended,
+# declare the Python requirements required to build your documentation
+# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
+python:
+ install:
+ - method: pip
+ path: .
+ extra_requirements:
+ - docs
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 000000000..0f8e53f3c
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,68 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# This file only contains a selection of the most common options. For a full
+# list see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+
+# -- Project information -----------------------------------------------------
+# Source - https://stackoverflow.com/a/75396624
+# Posted by Jan, modified by community. See post 'Timeline' for change history
+# Retrieved 2026-06-19, License - CC BY-SA 4.0
+
+# conf.py
+
+try:
+ import tomllib
+except ImportError:
+ import tomli as tomllib
+
+from pathlib import Path
+import importlib.metadata
+
+with open(Path(__file__).parent.parent / "pyproject.toml", "rb") as f:
+ toml = tomllib.load(f)
+
+# -- Project information -----------------------------------------------------
+
+project = toml["project"]["name"]
+release = importlib.metadata.version(project)
+version = ".".join(release.split(".")[:2])
+
+# -- General configuration ---------------------------------------------------
+# -- General configuration
+
+extensions = [
+ "sphinx.ext.duration",
+ "sphinx.ext.doctest",
+ "sphinx.ext.autodoc",
+ "sphinx.ext.autosummary",
+ "sphinx.ext.intersphinx",
+ "sphinx.ext.napoleon",
+]
+
+intersphinx_mapping = {
+ "rtd": ("https://docs.readthedocs.io/en/stable/", None),
+ "python": ("https://docs.python.org/3/", None),
+ "sphinx": ("https://www.sphinx-doc.org/en/master/", None),
+}
+intersphinx_disabled_domains = ["std"]
+
+templates_path = ["_templates"]
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This pattern also affects html_static_path and html_extra_path.
+exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
+
+# -- Options for HTML output -------------------------------------------------
+
+# The theme to use for HTML and HTML Help pages. See the documentation for
+# a list of builtin themes.
+#
+html_theme = "furo"
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = []
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 000000000..5ae5061f0
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,9 @@
+tableauserverclient
+===================
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Contents:
+
+.. automodule:: tableauserverclient
+ :members:
diff --git a/pyproject.toml b/pyproject.toml
index 8134f73f1..56aa39cde 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,6 +35,7 @@ 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",
"pytest-xdist", "requests-mock>=1.0,<2.0", "types-requests>=2.32.4.20250913"]
+docs = ["sphinx", "tomli", "furo"]
[tool.setuptools.package-data]
# Only include data for tableauserverclient, not for samples, test, docs
diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py
index 7241f23ca..b90fae944 100644
--- a/tableauserverclient/__init__.py
+++ b/tableauserverclient/__init__.py
@@ -58,6 +58,24 @@
WorkbookItem,
)
+from tableauserverclient.types import (
+ AddResponse,
+ FilePath,
+ FileObject,
+ FileObjectR,
+ FileObjectW,
+ HasIdpConfigurationID,
+ HyperAction,
+ HyperActionCondition,
+ HyperActionRow,
+ HyperActionTable,
+ IDPAttributes,
+ IDPProperty,
+ PathOrFile,
+ PathOrFileR,
+ PathOrFileW,
+)
+
from tableauserverclient.server import (
CSVRequestOptions,
ExcelRequestOptions,
@@ -76,6 +94,7 @@
)
__all__ = [
+ "AddResponse",
"BackgroundJobItem",
"CollectionItem",
"ColumnItem",
@@ -96,13 +115,24 @@
"FailedSignInError",
"FavoriteItem",
"FileuploadItem",
+ "FilePath",
+ "FileObject",
+ "FileObjectR",
+ "FileObjectW",
"Filter",
"FlowItem",
"FlowRunItem",
"get_versions",
"GroupItem",
"GroupSetItem",
+ "HasIdpConfigurationID",
"HourlyInterval",
+ "HyperAction",
+ "HyperActionCondition",
+ "HyperActionRow",
+ "HyperActionTable",
+ "IDPAttributes",
+ "IDPProperty",
"ImageRequestOptions",
"IntervalItem",
"JobItem",
@@ -122,6 +152,9 @@
"Permission",
"PermissionsRule",
"PersonalAccessTokenAuth",
+ "PathOrFile",
+ "PathOrFileR",
+ "PathOrFileW",
"ProjectItem",
"RequestOptions",
"Resource",
diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py
index fa4b8cf9f..0dbadeda6 100644
--- a/tableauserverclient/models/connection_item.py
+++ b/tableauserverclient/models/connection_item.py
@@ -150,16 +150,7 @@ def from_response(cls, resp, ns) -> list["ConnectionItem"]:
@classmethod
def from_xml_element(cls, parsed_response, ns) -> list["ConnectionItem"]:
- """
-
-
-
-
-
-
-
-
- """
+ """Parse connection items from an XML ```` element."""
all_connection_items: list["ConnectionItem"] = list()
all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns)
diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py
index f684c22d4..82aa8e080 100644
--- a/tableauserverclient/models/job_item.py
+++ b/tableauserverclient/models/job_item.py
@@ -27,7 +27,7 @@ class JobItem:
Parameters
----------
- id_ : str
+ id : str
The identifier of the job.
job_type : str
diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py
index 382ca63db..8cea6a7ab 100644
--- a/tableauserverclient/models/site_item.py
+++ b/tableauserverclient/models/site_item.py
@@ -52,10 +52,13 @@ class SiteItem:
cause an error.
tier_explorer_capacity: int
+ (Optional) The maximum number of licenses for users with the Explorer role allowed on a site.
+
tier_creator_capacity: int
+ (Optional) The maximum number of licenses for users with the Creator role allowed on a site.
+
tier_viewer_capacity: int
- (Optional) The maximum number of licenses for users with the Creator,
- Explorer, or Viewer role, respectively, allowed on a site.
+ (Optional) The maximum number of licenses for users with the Viewer role allowed on a site.
storage_quota: int
(Optional) Specifies the maximum amount of space for the new site, in
diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py
index b343f3c22..a8a672a4a 100644
--- a/tableauserverclient/models/task_item.py
+++ b/tableauserverclient/models/task_item.py
@@ -13,7 +13,7 @@ class TaskItem:
Parameters
----------
- id_ : str
+ id : str
The ID of the task.
task_type : str
diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py
index 55f0c7ea2..105140677 100644
--- a/tableauserverclient/server/endpoint/custom_views_endpoint.py
+++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py
@@ -32,12 +32,8 @@
update the name or owner of a custom view.
"""
-FilePath = str | os.PathLike
-FileObject = io.BufferedReader | io.BytesIO
-FileObjectR = io.BufferedReader | io.BytesIO
-FileObjectW = io.BufferedWriter | io.BytesIO
-PathOrFileR = FilePath | FileObjectR
-PathOrFileW = FilePath | FileObjectW
+from tableauserverclient.types import FilePath, FileObject, FileObjectR, FileObjectW, PathOrFileR, PathOrFileW
+
io_types_r = (io.BufferedReader, io.BytesIO)
io_types_w = (io.BufferedWriter, io.BytesIO)
diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py
index 30c66d7ad..b9ae2b1cc 100644
--- a/tableauserverclient/server/endpoint/datasources_endpoint.py
+++ b/tableauserverclient/server/endpoint/datasources_endpoint.py
@@ -6,16 +6,29 @@
from contextlib import closing
from pathlib import Path
-from typing import Literal, TYPE_CHECKING, TypedDict, TypeVar, overload
+from typing import Literal, TYPE_CHECKING, TypeVar, overload
from collections.abc import Iterable, Sequence
from tableauserverclient.models.dqw_item import DQWItem
from tableauserverclient.server.query import QuerySet
+from tableauserverclient.types import (
+ FilePath,
+ FileObject,
+ FileObjectR,
+ FileObjectW,
+ PathOrFile,
+ PathOrFileR,
+ PathOrFileW,
+ HyperAction,
+ HyperActionCondition,
+ HyperActionRow,
+ HyperActionTable,
+ AddResponse,
+)
if TYPE_CHECKING:
from tableauserverclient.server import Server
from tableauserverclient.models import PermissionsRule
- from .schedules_endpoint import AddResponse
from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint
from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in
@@ -50,53 +63,6 @@
io_types_r = (io.BytesIO, io.BufferedReader)
io_types_w = (io.BytesIO, io.BufferedWriter)
-FilePath = str | os.PathLike
-FileObject = io.BufferedReader | io.BytesIO
-PathOrFile = FilePath | FileObject
-
-FileObjectR = io.BufferedReader | io.BytesIO
-FileObjectW = io.BufferedWriter | io.BytesIO
-PathOrFileR = FilePath | FileObjectR
-PathOrFileW = FilePath | FileObjectW
-
-
-HyperActionCondition = TypedDict(
- "HyperActionCondition",
- {
- "op": str,
- "target-col": str,
- "source-col": str,
- },
-)
-
-HyperActionRow = TypedDict(
- "HyperActionRow",
- {
- "action": Literal[
- "update",
- "upsert",
- "delete",
- ],
- "source-table": str,
- "target-table": str,
- "condition": HyperActionCondition,
- },
-)
-
-HyperActionTable = TypedDict(
- "HyperActionTable",
- {
- "action": Literal[
- "insert",
- "replace",
- ],
- "source-table": str,
- "target-table": str,
- },
-)
-
-HyperAction = HyperActionTable | HyperActionRow
-
_UNSET = object()
diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py
index ea7b59526..e5c2dbf6b 100644
--- a/tableauserverclient/server/endpoint/flows_endpoint.py
+++ b/tableauserverclient/server/endpoint/flows_endpoint.py
@@ -41,18 +41,12 @@
from tableauserverclient.helpers.logging import logger
+from tableauserverclient.types import FilePath, FileObjectR, FileObjectW, PathOrFileR, PathOrFileW, AddResponse
+
if TYPE_CHECKING:
from tableauserverclient.models import DQWItem
from tableauserverclient.models.permissions_item import PermissionsRule
from tableauserverclient.server.request_options import RequestOptions
- from tableauserverclient.server.endpoint.schedules_endpoint import AddResponse
-
-
-FilePath = str | os.PathLike
-FileObjectR = io.BufferedReader | io.BytesIO
-FileObjectW = io.BufferedWriter | io.BytesIO
-PathOrFileR = FilePath | FileObjectR
-PathOrFileW = FilePath | FileObjectW
class Flows(QuerysetEndpoint[FlowItem], TaggingMixin[FlowItem]):
diff --git a/tableauserverclient/server/endpoint/oidc_endpoint.py b/tableauserverclient/server/endpoint/oidc_endpoint.py
index 1e6abd559..7b583f350 100644
--- a/tableauserverclient/server/endpoint/oidc_endpoint.py
+++ b/tableauserverclient/server/endpoint/oidc_endpoint.py
@@ -1,26 +1,15 @@
-from typing import Protocol, Union, TYPE_CHECKING
+from typing import TYPE_CHECKING
from tableauserverclient.models.oidc_item import SiteOIDCConfiguration
from tableauserverclient.server.endpoint import Endpoint
from tableauserverclient.server.request_factory import RequestFactory
from tableauserverclient.server.endpoint.endpoint import api
+from tableauserverclient.types import IDPAttributes, IDPProperty, HasIdpConfigurationID
if TYPE_CHECKING:
from tableauserverclient.models.site_item import SiteAuthConfiguration
from tableauserverclient.server.server import Server
-class IDPAttributes(Protocol):
- idp_configuration_id: str
-
-
-class IDPProperty(Protocol):
- @property
- def idp_configuration_id(self) -> str: ...
-
-
-HasIdpConfigurationID = str | IDPAttributes
-
-
class OIDC(Endpoint):
def __init__(self, server: "Server") -> None:
self.parent_srv = server
diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py
index 321d0120b..fc92661a7 100644
--- a/tableauserverclient/server/endpoint/schedules_endpoint.py
+++ b/tableauserverclient/server/endpoint/schedules_endpoint.py
@@ -2,7 +2,6 @@
import copy
import logging
import warnings
-from collections import namedtuple
from typing import TYPE_CHECKING, Any, Callable, Literal, overload
from .endpoint import Endpoint, api, parameter_added_in
@@ -10,10 +9,10 @@
from tableauserverclient.server import RequestFactory
from tableauserverclient.models import PaginationItem, ScheduleItem, TaskItem, ExtractItem
from tableauserverclient.models.schedule_item import parse_batch_schedule_state
+from tableauserverclient.types import AddResponse
from tableauserverclient.helpers.logging import logger
-AddResponse = namedtuple("AddResponse", ("result", "error", "warnings", "task_created"))
OK = AddResponse(result=True, error=None, warnings=None, task_created=None)
if TYPE_CHECKING:
diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py
index dd9722cbe..9d85725fb 100644
--- a/tableauserverclient/server/endpoint/workbooks_endpoint.py
+++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py
@@ -40,11 +40,20 @@
)
from collections.abc import Iterable, Sequence
+from tableauserverclient.types import (
+ FilePath,
+ FileObject,
+ FileObjectR,
+ FileObjectW,
+ PathOrFileR,
+ PathOrFileW,
+ AddResponse,
+)
+
if TYPE_CHECKING:
from tableauserverclient.server import Server
from tableauserverclient.server.request_options import RequestOptions, PDFRequestOptions, PPTXRequestOptions
from tableauserverclient.models import DatasourceItem
- from tableauserverclient.server.endpoint.schedules_endpoint import AddResponse
io_types_r = (io.BytesIO, io.BufferedReader)
io_types_w = (io.BytesIO, io.BufferedWriter)
@@ -56,14 +65,6 @@
from tableauserverclient.helpers.logging import logger
-FilePath = str | os.PathLike
-FileObject = io.BufferedReader | io.BytesIO
-FileObjectR = io.BufferedReader | io.BytesIO
-FileObjectW = io.BufferedWriter | io.BytesIO
-PathOrFileR = FilePath | FileObjectR
-PathOrFileW = FilePath | FileObjectW
-
-
_UNSET = object()
diff --git a/tableauserverclient/types.py b/tableauserverclient/types.py
new file mode 100644
index 000000000..2a6b25ce0
--- /dev/null
+++ b/tableauserverclient/types.py
@@ -0,0 +1,62 @@
+import io
+import os
+from collections import namedtuple
+from typing import Literal, Protocol, TypedDict
+
+# File path and object type aliases used across publish/download methods
+FilePath = str | os.PathLike
+FileObject = io.BufferedReader | io.BytesIO
+FileObjectR = io.BufferedReader | io.BytesIO
+FileObjectW = io.BufferedWriter | io.BytesIO
+PathOrFile = FilePath | FileObject
+PathOrFileR = FilePath | FileObjectR
+PathOrFileW = FilePath | FileObjectW
+
+
+# Hyper action types for Datasources.update_hyper_data()
+HyperActionCondition = TypedDict(
+ "HyperActionCondition",
+ {
+ "op": str,
+ "target-col": str,
+ "source-col": str,
+ },
+)
+
+HyperActionRow = TypedDict(
+ "HyperActionRow",
+ {
+ "action": Literal["update", "upsert", "delete"],
+ "source-table": str,
+ "target-table": str,
+ "condition": HyperActionCondition,
+ },
+)
+
+HyperActionTable = TypedDict(
+ "HyperActionTable",
+ {
+ "action": Literal["insert", "replace"],
+ "source-table": str,
+ "target-table": str,
+ },
+)
+
+HyperAction = HyperActionTable | HyperActionRow
+
+
+# Return type for Schedules.add_to_schedule()
+AddResponse = namedtuple("AddResponse", ("result", "error", "warnings", "task_created"))
+
+
+# IDP types for OIDC endpoint
+class IDPAttributes(Protocol):
+ idp_configuration_id: str
+
+
+class IDPProperty(Protocol):
+ @property
+ def idp_configuration_id(self) -> str: ...
+
+
+HasIdpConfigurationID = str | IDPAttributes
diff --git a/test_e2e/assets/WorkbookWithExtract.twbx b/test_e2e/assets/WorkbookWithExtract.twbx
new file mode 100644
index 000000000..b6025b36c
Binary files /dev/null and b/test_e2e/assets/WorkbookWithExtract.twbx differ
diff --git a/test_e2e/assets/WorldIndicators.tdsx b/test_e2e/assets/WorldIndicators.tdsx
new file mode 100644
index 000000000..6e041442b
Binary files /dev/null and b/test_e2e/assets/WorldIndicators.tdsx differ
diff --git a/test_e2e/conftest.py b/test_e2e/conftest.py
index 376d41a09..2a90b8f04 100644
--- a/test_e2e/conftest.py
+++ b/test_e2e/conftest.py
@@ -5,6 +5,7 @@
def pytest_configure(config):
config.addinivalue_line("markers", "e2e: mark test as end-to-end (requires a real Tableau server)")
+ config.addinivalue_line("markers", "site_admin: mark test as requiring SiteAdmin permissions")
@pytest.fixture(scope="session")
@@ -13,10 +14,14 @@ def server():
Authenticated TSC server session for e2e tests.
Required environment variables:
- TABLEAU_SERVER — server URL, e.g. https://10ax.online.tableau.com
- TABLEAU_SITE — site content URL
- TABLEAU_TOKEN — personal access token value
- TABLEAU_TOKEN_NAME — personal access token name
+ TABLEAU_SERVER -- server URL, e.g. https://10ax.online.tableau.com
+ TABLEAU_SITE -- site content URL
+ TABLEAU_TOKEN -- personal access token value
+ TABLEAU_TOKEN_NAME -- personal access token name
+
+ Optional:
+ TABLEAU_IS_ADMIN -- set to "1" or "true" if the token belongs to a SiteAdmin account.
+ Tests marked with @pytest.mark.site_admin are skipped when not set.
"""
url = os.environ.get("TABLEAU_SERVER")
site = os.environ.get("TABLEAU_SITE", "")
@@ -30,3 +35,28 @@ def server():
auth = TSC.PersonalAccessTokenAuth(token_name, token, site)
with server.auth.sign_in(auth):
yield server
+
+
+@pytest.fixture(scope="session")
+def is_admin():
+ val = os.environ.get("TABLEAU_IS_ADMIN", "").strip().lower()
+ return val in ("1", "true", "yes")
+
+
+def pytest_runtest_setup(item):
+ if item.get_closest_marker("site_admin"):
+ val = os.environ.get("TABLEAU_IS_ADMIN", "").strip().lower()
+ if val not in ("1", "true", "yes"):
+ pytest.skip("Skipping site_admin test: set TABLEAU_IS_ADMIN=1 to run")
+
+
+@pytest.fixture(scope="session")
+def project_id(server):
+ """Return the ID of the project named by TABLEAU_PROJECT (default 'Default')."""
+ project_name = os.environ.get("TABLEAU_PROJECT", "Sandbox")
+ opts = TSC.RequestOptions()
+ opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, project_name))
+ projects, _ = server.projects.get(opts)
+ if not projects:
+ pytest.skip(f"Project {project_name!r} not found -- set TABLEAU_PROJECT env var")
+ return projects[0].id
diff --git a/test_e2e/test_publisher.py b/test_e2e/test_publisher.py
new file mode 100644
index 000000000..f48980d32
--- /dev/null
+++ b/test_e2e/test_publisher.py
@@ -0,0 +1,311 @@
+"""
+E2E tests for Publisher-level operations against a real Tableau server.
+
+Requires: TABLEAU_SERVER, TABLEAU_SITE, TABLEAU_TOKEN, TABLEAU_TOKEN_NAME
+Optional: TABLEAU_PROJECT (defaults to "Default")
+
+Run with:
+ pytest test_e2e/test_publisher.py -v -m e2e
+"""
+import os
+from pathlib import Path
+
+import pytest
+import tableauserverclient as TSC
+from tableauserverclient.models import Resource
+
+ASSETS_DIR = Path(__file__).parent / "assets"
+SAMPLE_WORKBOOK = ASSETS_DIR / "WorkbookWithoutExtract.twbx"
+EXTRACT_WORKBOOK = ASSETS_DIR / "WorkbookWithExtract.twbx"
+SAMPLE_DATASOURCE = ASSETS_DIR / "WorldIndicators.tdsx"
+
+pytestmark = pytest.mark.e2e
+
+
+# ---------------------------------------------------------------------------
+# Shared fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture(scope="module")
+def workbook(server, project_id):
+ """Publish a workbook for this module's tests, clean up after."""
+ wb = TSC.WorkbookItem(name="tsc-e2e-publisher-wb", project_id=project_id)
+ wb = server.workbooks.publish(wb, SAMPLE_WORKBOOK, TSC.Server.PublishMode.Overwrite)
+ yield wb
+ server.workbooks.delete(wb.id)
+
+
+# ---------------------------------------------------------------------------
+# Workbook CRUD
+# ---------------------------------------------------------------------------
+
+
+def test_workbook_publish_and_get(server, project_id):
+ """Published workbook is retrievable by id and has correct name/project."""
+ wb = TSC.WorkbookItem(name="tsc-e2e-publish-test", project_id=project_id)
+ wb = server.workbooks.publish(wb, SAMPLE_WORKBOOK, TSC.Server.PublishMode.Overwrite)
+ try:
+ fetched = server.workbooks.get_by_id(wb.id)
+ assert fetched.id == wb.id
+ assert fetched.name == "tsc-e2e-publish-test"
+ assert fetched.project_id == project_id
+ finally:
+ server.workbooks.delete(wb.id)
+
+
+def test_workbook_update(server, workbook):
+ """Updating a workbook's name and description persists on the server."""
+ original_name = workbook.name
+ workbook.name = "tsc-e2e-publisher-wb-renamed"
+ workbook.description = "updated by e2e test"
+ updated = server.workbooks.update(workbook)
+ try:
+ assert updated.name == "tsc-e2e-publisher-wb-renamed"
+ assert updated.description == "updated by e2e test"
+ finally:
+ workbook.name = original_name
+ workbook.description = ""
+ server.workbooks.update(workbook)
+
+
+def test_workbook_download(server, workbook, tmp_path):
+ """Downloaded workbook file exists and is non-empty."""
+ path = server.workbooks.download(workbook.id, str(tmp_path))
+ assert Path(path).exists()
+ assert Path(path).stat().st_size > 0
+
+
+def test_workbook_populate_views(server, workbook):
+ """populate_views returns at least one view for the test workbook."""
+ server.workbooks.populate_views(workbook)
+ assert workbook.views is not None
+ assert len(workbook.views) > 0
+
+
+def test_workbook_populate_connections(server, workbook):
+ """populate_connections returns a list (may be empty for extract-only wb)."""
+ server.workbooks.populate_connections(workbook)
+ assert workbook.connections is not None
+
+
+def test_workbook_preview_image(server, workbook):
+ """populate_preview_image succeeds without error (image may be empty for freshly-published workbooks)."""
+ server.workbooks.populate_preview_image(workbook)
+ assert workbook.preview_image is not None
+
+
+def test_workbook_tags(server, workbook):
+ """Tags added to a workbook round-trip correctly and can be removed."""
+ server.workbooks.add_tags(workbook, ["e2e-tag-a", "e2e-tag-b"])
+ fetched = server.workbooks.get_by_id(workbook.id)
+ try:
+ assert "e2e-tag-a" in fetched.tags
+ assert "e2e-tag-b" in fetched.tags
+ finally:
+ server.workbooks.delete_tags(workbook, ["e2e-tag-a", "e2e-tag-b"])
+ fetched = server.workbooks.get_by_id(workbook.id)
+ assert "e2e-tag-a" not in fetched.tags
+
+
+# ---------------------------------------------------------------------------
+# View exports
+# ---------------------------------------------------------------------------
+
+
+def test_view_export_png(server, workbook, tmp_path):
+ """A view can be exported as a PNG image."""
+ server.workbooks.populate_views(workbook)
+ view = workbook.views[0]
+ server.views.populate_image(view)
+ assert view.image is not None
+ assert len(view.image) > 0
+
+
+def test_view_export_pdf(server, workbook, tmp_path):
+ """A view can be exported as a PDF."""
+ server.workbooks.populate_views(workbook)
+ view = workbook.views[0]
+ opts = TSC.PDFRequestOptions()
+ server.views.populate_pdf(view, opts)
+ assert view.pdf is not None
+ assert len(view.pdf) > 0
+
+
+def test_view_export_csv(server, workbook):
+ """A view can be exported as CSV data."""
+ server.workbooks.populate_views(workbook)
+ view = workbook.views[0]
+ server.views.populate_csv(view)
+ assert view.csv is not None
+
+
+# ---------------------------------------------------------------------------
+# Datasource CRUD
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture(scope="module")
+def extract_workbook(server, project_id):
+ """Publish a workbook with an extract for refresh/extract tests, clean up after."""
+ wb = TSC.WorkbookItem(name="tsc-e2e-extract-wb", project_id=project_id)
+ wb = server.workbooks.publish(wb, EXTRACT_WORKBOOK, TSC.Server.PublishMode.Overwrite)
+ yield wb
+ server.workbooks.delete(wb.id)
+
+
+@pytest.fixture(scope="module")
+def datasource(server, project_id):
+ """Publish a datasource for this module's tests, clean up after."""
+ ds = TSC.DatasourceItem(project_id=project_id, name="tsc-e2e-publisher-ds")
+ ds = server.datasources.publish(ds, str(SAMPLE_DATASOURCE), TSC.Server.PublishMode.Overwrite)
+ yield ds
+ server.datasources.delete(ds.id)
+
+
+def test_datasource_publish_and_get(server, datasource):
+ """Published datasource is retrievable by id."""
+ fetched = server.datasources.get_by_id(datasource.id)
+ assert fetched.id == datasource.id
+ assert fetched.name == "tsc-e2e-publisher-ds"
+
+
+def test_datasource_update(server, datasource):
+ """Updating datasource description persists."""
+ datasource.description = "updated by e2e test"
+ updated = server.datasources.update(datasource)
+ assert updated.description == "updated by e2e test"
+
+
+def test_datasource_download(server, datasource, tmp_path):
+ """Downloaded datasource file exists and is non-empty."""
+ path = server.datasources.download(datasource.id, str(tmp_path))
+ assert Path(path).exists()
+ assert Path(path).stat().st_size > 0
+
+
+def test_datasource_populate_connections(server, datasource):
+ """populate_connections returns a list for a published datasource."""
+ server.datasources.populate_connections(datasource)
+ assert datasource.connections is not None
+
+
+def test_datasource_tags(server, datasource):
+ """Tags added to a datasource round-trip correctly."""
+ server.datasources.add_tags(datasource, ["e2e-ds-tag"])
+ fetched = server.datasources.get_by_id(datasource.id)
+ try:
+ assert "e2e-ds-tag" in fetched.tags
+ finally:
+ server.datasources.delete_tags(datasource, ["e2e-ds-tag"])
+
+
+# ---------------------------------------------------------------------------
+# Favorites
+# ---------------------------------------------------------------------------
+
+
+def test_favorites_workbook(server, workbook):
+ """A workbook can be added to and removed from favorites."""
+ user = TSC.UserItem()
+ user.id = server.user_id
+ server.favorites.add_favorite(user, Resource.Workbook, workbook)
+ server.favorites.get(user)
+ assert any(f.id == workbook.id for f in user.favorites.get("workbooks", []))
+ server.favorites.delete_favorite_workbook(user, workbook)
+
+
+def test_favorites_view(server, workbook):
+ """A view can be added to and removed from favorites."""
+ server.workbooks.populate_views(workbook)
+ view = workbook.views[0]
+ user = TSC.UserItem()
+ user.id = server.user_id
+ server.favorites.add_favorite_view(user, view)
+ server.favorites.get(user)
+ assert any(f.id == view.id for f in user.favorites.get("views", []))
+ server.favorites.delete_favorite_view(user, view)
+
+
+def test_favorites_datasource(server, datasource):
+ """A datasource can be added to and removed from favorites."""
+ user = TSC.UserItem()
+ user.id = server.user_id
+ server.favorites.add_favorite_datasource(user, datasource)
+ server.favorites.get(user)
+ assert any(f.id == datasource.id for f in user.favorites.get("datasources", []))
+ server.favorites.delete_favorite_datasource(user, datasource)
+
+
+# ---------------------------------------------------------------------------
+# Pagination
+# ---------------------------------------------------------------------------
+
+
+def test_pager_workbooks(server):
+ """Pager iterates over all workbooks without error."""
+ count = sum(1 for _ in TSC.Pager(server.workbooks))
+ assert count >= 0
+
+
+def test_queryset_filter(server):
+ """QuerySet filter returns only matching workbooks."""
+ results = list(server.workbooks.filter(name="tsc-e2e-publisher-wb"))
+ assert all(wb.name == "tsc-e2e-publisher-wb" for wb in results)
+
+
+# ---------------------------------------------------------------------------
+# Workbook refresh and extract
+# ---------------------------------------------------------------------------
+
+
+def test_workbook_refresh(server, extract_workbook):
+ """Triggering a workbook refresh returns a job item."""
+ job = server.workbooks.refresh(extract_workbook)
+ assert job.id is not None
+
+
+def test_workbook_create_and_delete_extract(server, extract_workbook):
+ """An extract can be deleted from a workbook and then recreated.
+
+ Requires a workbook asset whose datasource is supported on the target server OS.
+ WorkbookWithExtract.twbx uses MS Access which fails on Linux servers -- replace
+ the asset with a compatible .twbx to enable this test.
+ """
+ pytest.skip("WorkbookWithExtract.twbx uses MS Access (not supported on Linux) -- replace asset to enable")
+
+
+# ---------------------------------------------------------------------------
+# Datasource refresh
+# ---------------------------------------------------------------------------
+
+
+def test_datasource_refresh(server, datasource):
+ """Triggering a datasource refresh returns a job item."""
+ job = server.datasources.refresh(datasource)
+ assert job.id is not None
+
+
+# ---------------------------------------------------------------------------
+# Metadata API
+# ---------------------------------------------------------------------------
+
+
+def test_metadata_query(server):
+ """Metadata GraphQL API returns a valid response structure."""
+ result = server.metadata.query(
+ """
+ {
+ publishedDatasourcesConnection(first: 5) {
+ nodes {
+ luid
+ name
+ }
+ }
+ }
+ """
+ )
+ assert "data" in result
+ assert "publishedDatasourcesConnection" in result["data"]
+
+
diff --git a/test_e2e/test_site_admin.py b/test_e2e/test_site_admin.py
new file mode 100644
index 000000000..16db9e804
--- /dev/null
+++ b/test_e2e/test_site_admin.py
@@ -0,0 +1,293 @@
+"""
+E2E tests for SiteAdmin-level operations against a real Tableau server.
+
+All tests in this file are marked with @pytest.mark.site_admin and are skipped
+unless TABLEAU_IS_ADMIN=1 (or "true"/"yes") is set in the environment. The token
+configured in TABLEAU_TOKEN must belong to an account with SiteAdminCreator
+or SiteAdminExplorer role.
+
+Run with:
+ TABLEAU_IS_ADMIN=1 pytest test_e2e/test_site_admin.py -v -m e2e
+"""
+from datetime import time
+from pathlib import Path
+
+import pytest
+import tableauserverclient as TSC
+
+ASSETS_DIR = Path(__file__).parent / "assets"
+SAMPLE_WORKBOOK = ASSETS_DIR / "WorkbookWithoutExtract.twbx"
+SAMPLE_DATASOURCE = ASSETS_DIR / "WorldIndicators.tdsx"
+
+pytestmark = [pytest.mark.e2e, pytest.mark.site_admin]
+
+
+# ---------------------------------------------------------------------------
+# Jobs (requires admin)
+# ---------------------------------------------------------------------------
+
+
+def test_jobs_get(server):
+ """jobs.get() returns a list without error."""
+ jobs, _ = server.jobs.get()
+ assert isinstance(jobs, list)
+
+
+# ---------------------------------------------------------------------------
+# Projects
+# ---------------------------------------------------------------------------
+
+
+def test_project_create_update_delete(server, project_id):
+ """A project can be created, updated, and deleted."""
+ project = TSC.ProjectItem(name="tsc-e2e-admin-project", description="created by e2e test")
+ project = server.projects.create(project)
+ try:
+ assert project.id is not None
+ fetched = server.projects.filter(name="tsc-e2e-admin-project")[0]
+ assert fetched.id == project.id
+
+ project.description = "updated by e2e test"
+ updated = server.projects.update(project)
+ assert updated.description == "updated by e2e test"
+ finally:
+ server.projects.delete(project.id)
+
+
+def test_nested_project(server):
+ """A nested (child) project can be created under a parent."""
+ parent = TSC.ProjectItem(name="tsc-e2e-parent-project")
+ parent = server.projects.create(parent)
+ try:
+ child = TSC.ProjectItem(name="tsc-e2e-child-project", parent_id=parent.id)
+ child = server.projects.create(child)
+ try:
+ assert child.parent_id == parent.id
+ finally:
+ server.projects.delete(child.id)
+ finally:
+ server.projects.delete(parent.id)
+
+
+def test_project_workbook_default_permissions(server):
+ """Workbook default permissions on a project can be updated and queried."""
+ project = TSC.ProjectItem(name="tsc-e2e-perm-project")
+ project = server.projects.create(project)
+ try:
+ server.projects.populate_workbook_default_permissions(project)
+ assert project.default_workbook_permissions is not None
+ finally:
+ server.projects.delete(project.id)
+
+
+# ---------------------------------------------------------------------------
+# Users
+# ---------------------------------------------------------------------------
+
+
+def test_user_add_and_remove(server):
+ """A user can be added to the site and removed."""
+ user = TSC.UserItem("tsc-e2e-testuser", "Unlicensed")
+ user = server.users.add(user)
+ try:
+ assert user.id is not None
+ fetched = server.users.get_by_id(user.id)
+ assert fetched.name == "tsc-e2e-testuser"
+ finally:
+ server.users.remove(user.id)
+
+
+# ---------------------------------------------------------------------------
+# Groups
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture(scope="module")
+def group(server):
+ """Create a group for group tests, clean up after."""
+ g = TSC.GroupItem("tsc-e2e-admin-group")
+ g = server.groups.create(g)
+ yield g
+ server.groups.delete(g.id)
+
+
+def test_group_create_and_get(server, group):
+ """Created group appears in the group list."""
+ results = list(server.groups.filter(name="tsc-e2e-admin-group"))
+ assert any(g.id == group.id for g in results)
+
+
+def test_group_add_and_remove_user(server, group):
+ """A user can be added to and removed from a group."""
+ user = TSC.UserItem("tsc-e2e-group-user", "Unlicensed")
+ user = server.users.add(user)
+ try:
+ server.groups.add_user(group, user.id)
+ server.groups.populate_users(group)
+ assert any(u.id == user.id for u in group.users)
+
+ server.groups.remove_user(group, user.id)
+ server.groups.populate_users(group)
+ assert all(u.id != user.id for u in group.users)
+ finally:
+ server.users.remove(user.id)
+
+
+# ---------------------------------------------------------------------------
+# Schedules
+# ---------------------------------------------------------------------------
+
+
+def test_schedule_create_and_delete(server):
+ """An extract schedule can be created and deleted."""
+ interval = TSC.HourlyInterval(start_time=time(3, 0), end_time=time(23, 0), interval_value=4)
+ schedule = TSC.ScheduleItem(
+ "tsc-e2e-hourly-schedule",
+ 50,
+ TSC.ScheduleItem.Type.Extract,
+ TSC.ScheduleItem.ExecutionOrder.Parallel,
+ interval,
+ )
+ schedule = server.schedules.create(schedule)
+ try:
+ assert schedule.id is not None
+ fetched = server.schedules.get_by_id(schedule.id)
+ assert fetched.name == "tsc-e2e-hourly-schedule"
+ finally:
+ server.schedules.delete(schedule.id)
+
+
+def test_schedule_add_workbook(server, project_id):
+ """A workbook can be added to an extract refresh schedule."""
+ interval = TSC.DailyInterval(start_time=time(4, 0))
+ schedule = TSC.ScheduleItem(
+ "tsc-e2e-daily-schedule",
+ 60,
+ TSC.ScheduleItem.Type.Extract,
+ TSC.ScheduleItem.ExecutionOrder.Serial,
+ interval,
+ )
+ schedule = server.schedules.create(schedule)
+ wb = TSC.WorkbookItem(name="tsc-e2e-schedule-wb", project_id=project_id)
+ wb = server.workbooks.publish(wb, SAMPLE_WORKBOOK, TSC.Server.PublishMode.Overwrite)
+ try:
+ server.schedules.add_to_schedule(schedule.id, wb)
+ tasks, _ = server.tasks.get()
+ assert any(getattr(t, "schedule_id", None) == schedule.id for t in tasks)
+ finally:
+ server.workbooks.delete(wb.id)
+ server.schedules.delete(schedule.id)
+
+
+# ---------------------------------------------------------------------------
+# Webhooks
+# ---------------------------------------------------------------------------
+
+
+def test_webhook_create_and_delete(server):
+ """A webhook can be created and deleted."""
+ webhook = TSC.WebhookItem()
+ webhook.name = "tsc-e2e-webhook"
+ webhook.url = "https://example.com/tsc-e2e-webhook"
+ webhook.event = "datasource-created"
+ webhook = server.webhooks.create(webhook)
+ try:
+ assert webhook.id is not None
+ all_webhooks, _ = server.webhooks.get()
+ assert any(w.id == webhook.id for w in all_webhooks)
+ finally:
+ server.webhooks.delete(webhook.id)
+
+
+# ---------------------------------------------------------------------------
+# Move workbook between projects
+# ---------------------------------------------------------------------------
+
+
+# ---------------------------------------------------------------------------
+# Connection update
+# ---------------------------------------------------------------------------
+
+
+def test_datasource_update_connection(server, project_id):
+ """A datasource connection's embed_password flag can be toggled via update_connection."""
+ ds = TSC.DatasourceItem(project_id=project_id, name="tsc-e2e-conn-ds")
+ ds = server.datasources.publish(ds, str(SAMPLE_DATASOURCE), TSC.Server.PublishMode.Overwrite)
+ try:
+ server.datasources.populate_connections(ds)
+ if not ds.connections:
+ pytest.skip("Published datasource has no connections to update")
+ conn = ds.connections[0]
+ conn.embed_password = False
+ updated_conn = server.datasources.update_connection(ds, conn)
+ assert updated_conn is not None
+ finally:
+ server.datasources.delete(ds.id)
+
+
+# ---------------------------------------------------------------------------
+# Data freshness policy
+# ---------------------------------------------------------------------------
+
+
+def test_workbook_data_freshness_policy(server, project_id):
+ """Workbook data freshness policy can be set to AlwaysLive and back to SiteDefault."""
+ wb = TSC.WorkbookItem(name="tsc-e2e-freshness-wb", project_id=project_id)
+ wb = server.workbooks.publish(wb, SAMPLE_WORKBOOK, TSC.Server.PublishMode.Overwrite)
+ try:
+ wb.data_freshness_policy = TSC.DataFreshnessPolicyItem(TSC.DataFreshnessPolicyItem.Option.AlwaysLive)
+ updated = server.workbooks.update(wb)
+ assert updated.data_freshness_policy.option == TSC.DataFreshnessPolicyItem.Option.AlwaysLive
+
+ wb.data_freshness_policy = TSC.DataFreshnessPolicyItem(TSC.DataFreshnessPolicyItem.Option.SiteDefault)
+ updated = server.workbooks.update(wb)
+ assert updated.data_freshness_policy.option == TSC.DataFreshnessPolicyItem.Option.SiteDefault
+ finally:
+ server.workbooks.delete(wb.id)
+
+
+# ---------------------------------------------------------------------------
+# Group filter and sort
+# ---------------------------------------------------------------------------
+
+
+def test_group_filter_by_name(server):
+ """Groups can be filtered by name using RequestOptions."""
+ g = TSC.GroupItem("tsc-e2e-filter-group")
+ g = server.groups.create(g)
+ try:
+ opts = TSC.RequestOptions()
+ opts.filter.add(
+ TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "tsc-e2e-filter-group")
+ )
+ results, _ = server.groups.get(req_options=opts)
+ assert len(results) == 1
+ assert results[0].id == g.id
+ finally:
+ server.groups.delete(g.id)
+
+
+def test_group_filter_queryset(server):
+ """Groups can be filtered using the django-style QuerySet interface."""
+ g = TSC.GroupItem("tsc-e2e-qs-group")
+ g = server.groups.create(g)
+ try:
+ results = list(server.groups.filter(name="tsc-e2e-qs-group"))
+ assert any(r.id == g.id for r in results)
+ finally:
+ server.groups.delete(g.id)
+
+
+def test_workbook_move_project(server, project_id):
+ """A workbook can be moved from one project to another."""
+ dest = TSC.ProjectItem(name="tsc-e2e-dest-project")
+ dest = server.projects.create(dest)
+ wb = TSC.WorkbookItem(name="tsc-e2e-move-wb", project_id=project_id)
+ wb = server.workbooks.publish(wb, SAMPLE_WORKBOOK, TSC.Server.PublishMode.Overwrite)
+ try:
+ wb.project_id = dest.id
+ updated = server.workbooks.update(wb)
+ assert updated.project_id == dest.id
+ finally:
+ server.workbooks.delete(wb.id)
+ server.projects.delete(dest.id)
diff --git a/test_e2e/test_tagging.py b/test_e2e/test_tagging.py
index 4af741255..6b4d26ec0 100644
--- a/test_e2e/test_tagging.py
+++ b/test_e2e/test_tagging.py
@@ -5,7 +5,6 @@
TABLEAU_SERVER=https://... TABLEAU_SITE=mysite TABLEAU_TOKEN=... TABLEAU_TOKEN_NAME=... \
pytest test_e2e/test_tagging.py -v
"""
-import os
from pathlib import Path
import pytest
@@ -18,21 +17,9 @@
@pytest.fixture(scope="module")
-def workbook(server):
- """Publish a workbook for tagging tests, clean up after.
-
- Uses TABLEAU_PROJECT env var if set, otherwise falls back to the first
- project named 'Default' or 'Personal Work', then the first available project.
- """
- project_name = os.environ.get("TABLEAU_PROJECT", "Default")
- opts = TSC.RequestOptions()
- opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, project_name))
- projects, _ = server.projects.get(opts)
- if not projects:
- pytest.skip(f"Project {project_name!r} not found — set TABLEAU_PROJECT env var")
- project = projects[0]
-
- wb = TSC.WorkbookItem(name="tsc-e2e-tagging-test", project_id=project.id)
+def workbook(server, project_id):
+ """Publish a workbook for tagging tests, clean up after."""
+ wb = TSC.WorkbookItem(name="tsc-e2e-tagging-test", project_id=project_id)
wb = server.workbooks.publish(wb, SAMPLE_WORKBOOK, TSC.Server.PublishMode.Overwrite)
yield wb
server.workbooks.delete(wb.id)