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/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/models/user_item.py b/tableauserverclient/models/user_item.py
index 0ba1e8eb2..05e0f5916 100644
--- a/tableauserverclient/models/user_item.py
+++ b/tableauserverclient/models/user_item.py
@@ -432,36 +432,37 @@ class ColumnType(IntEnum):
EMAIL = 6
AUTH = 7
- MAX = 7
+ MAX = 8 # total number of columns (not last index)
# 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.ColumnType.MAX:
+ 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.ColumnType.MAX:
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]
+ auth = UserItem.CSVImport._auth_canonical().get(raw_auth.lower()) if raw_auth else 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,
@@ -491,6 +492,16 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, l
# Some fields in the import file are restricted to specific values
# Iterate through each field and validate the given value against hardcoded constraints
+ @staticmethod
+ def _auth_canonical() -> dict[str, str]:
+ """Lowercase → canonical form mapping for Auth values."""
+ return {
+ "saml": "SAML",
+ "openid": "OpenID",
+ "serverdefault": "ServerDefault",
+ "tableauidwithmfa": "TableauIDWithMFA",
+ }
+
@staticmethod
def _validate_import_line_or_throw(incoming, logger) -> None:
_valid_attributes: list[list[str]] = [
@@ -501,7 +512,12 @@ 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
+ [
+ "SAML",
+ "OpenID",
+ "ServerDefault",
+ "TableauIDWithMFA",
+ ], # auth — normalized by _auth_canonical before comparison
]
line = list(map(str.strip, incoming.split(",")))
@@ -511,10 +527,16 @@ def _validate_import_line_or_throw(incoming, logger) -> None:
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]
+ # normalize case for fields with a restricted value set
+ if valid:
+ if i == UserItem.CSVImport.ColumnType.AUTH:
+ value = UserItem.CSVImport._auth_canonical().get(value.lower(), value)
+ else:
+ value = value.lower()
+ logger.debug(f"column {UserItem.CSVImport.ColumnType(i).name}: {value}")
+ UserItem.CSVImport._validate_attribute_value(value, valid, UserItem.CSVImport.ColumnType(i))
# Given a restricted set of possible values, confirm the item is in that set
@staticmethod
diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py
index 149aab5ec..a2374a27d 100644
--- a/tableauserverclient/server/endpoint/endpoint.py
+++ b/tableauserverclient/server/endpoint/endpoint.py
@@ -374,6 +374,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/test/test_user_model.py b/test/test_user_model.py
index 49e8dc25c..1b9b74f4e 100644
--- a/test/test_user_model.py
+++ b/test/test_user_model.py
@@ -136,3 +136,36 @@ 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, AttributeError)):
+ TSC.UserItem.CSVImport.create_user_from_line("u, p, n, creator, none, yes, email, SAML, extra")
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)