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)