From 1e2b18742119ec0dc03a1e3b16ed841b5ad5d92b Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Wed, 17 Jun 2026 10:39:54 -0700 Subject: [PATCH 1/3] fix: quote tag labels containing spaces or commas in XML request The server's TagUtil.parseTags splits unquoted labels on spaces and commas, causing tags like "Yearly Sales" to be stored as two separate tags. Wrapping labels in double quotes prevents the split; the server strips the quotes before storing. Fixes #1738 Co-Authored-By: Claude Sonnet 4.6 --- tableauserverclient/server/request_factory.py | 12 ++++++++++-- test/test_tagging.py | 16 +++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index e22e43e2f..2cabd70aa 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -921,13 +921,21 @@ def update_req(self, table_item): content_types = Iterable["ColumnItem | DatabaseItem | DatasourceItem | FlowItem | TableItem | WorkbookItem"] +def _encode_tag_label(tag: str) -> str: + # The server splits unquoted labels on spaces or commas. Wrap in double + # quotes so labels containing spaces are stored as a single tag. + if " " in tag or "," in tag: + return f'"{tag}"' + return tag + + class TagRequest: def add_req(self, tag_set): xml_request = ET.Element("tsRequest") tags_element = ET.SubElement(xml_request, "tags") for tag in tag_set: tag_element = ET.SubElement(tags_element, "tag") - tag_element.attrib["label"] = tag + tag_element.attrib["label"] = _encode_tag_label(tag) return ET.tostring(xml_request) @_tsrequest_wrapped @@ -936,7 +944,7 @@ def batch_create(self, element: ET.Element, tags: set[str], content: content_typ tags_element = ET.SubElement(tag_batch, "tags") for tag in tags: tag_element = ET.SubElement(tags_element, "tag") - tag_element.attrib["label"] = tag + tag_element.attrib["label"] = _encode_tag_label(tag) contents_element = ET.SubElement(tag_batch, "contents") for item in content: content_element = ET.SubElement(contents_element, "content") diff --git a/test/test_tagging.py b/test/test_tagging.py index 842e27c12..c8fbe142a 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -21,7 +21,9 @@ def get_server() -> TSC.Server: return server -def add_tag_xml_response_factory(tags: Iterable[str]) -> str: +def add_tag_xml_response_factory(tags: Iterable[str] | str) -> str: + if isinstance(tags, str): + tags = [tags] root = ET.Element("tsResponse") tags_element = ET.SubElement(root, "tags") for tag in tags: @@ -242,3 +244,15 @@ def test_tags_batch_delete(get_server) -> None: tag_result = server.tags.batch_delete(tags, content) assert set(tag_result) == set(tags) + + +def test_tag_with_spaces_is_quoted_in_request() -> None: + """Tags containing spaces must be quoted in the XML request to prevent server-side splitting.""" + from tableauserverclient.server.request_factory import RequestFactory + + tag_set = {"Yearly Sales", "simple"} + xml_bytes = RequestFactory.Tag.add_req(tag_set) + root = ET.fromstring(xml_bytes) + labels = {tag.get("label") for tag in root.findall(".//tag")} + assert '"Yearly Sales"' in labels + assert "simple" in labels From 19f28977b0668f557f856638fb4ca0b947aa99be Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Wed, 17 Jun 2026 20:11:39 -0700 Subject: [PATCH 2/3] fix: quote tag labels containing spaces or commas; add e2e test suite The Tableau server splits unquoted tag labels on spaces and commas (via TagUtil.parseTags), so "Yearly Sales" becomes two tags "Yearly" and "Sales". Wrapping labels in double quotes prevents the split; the server strips the quotes before storing. Also adds the first e2e test infrastructure to the repo: - test_e2e/ directory with a session-scoped server fixture reading credentials from env vars (TABLEAU_SERVER, TABLEAU_SITE, TABLEAU_TOKEN, TABLEAU_TOKEN_NAME, TABLEAU_PROJECT) - test_e2e/test_tagging.py validates the tag quoting fix against a real Tableau server - contributing.md documents how to run unit and e2e tests Fixes #1738 Co-Authored-By: Claude Sonnet 4.6 --- contributing.md | 80 ++++++++++++++------ pyproject.toml | 1 + test_e2e/__init__.py | 0 test_e2e/assets/WorkbookWithoutExtract.twbx | Bin 0 -> 20782 bytes test_e2e/conftest.py | 32 ++++++++ test_e2e/test_tagging.py | 78 +++++++++++++++++++ 6 files changed, 166 insertions(+), 25 deletions(-) create mode 100644 test_e2e/__init__.py create mode 100644 test_e2e/assets/WorkbookWithoutExtract.twbx create mode 100644 test_e2e/conftest.py create mode 100644 test_e2e/test_tagging.py diff --git a/contributing.md b/contributing.md index a0132919f..bba25e1d6 100644 --- a/contributing.md +++ b/contributing.md @@ -1,25 +1,55 @@ -# Contributing - -We welcome contributions to this project! - -Contribution can include, but are not limited to, any of the following: - -* File an Issue -* Request a Feature -* Implement a Requested Feature -* Fix an Issue/Bug -* Add/Fix documentation - -## Issues and Feature Requests - -To submit an issue/bug report, or to request a feature, please submit a [GitHub issue](https://github.com/tableau/server-client-python/issues) to the repo. - -If you are submitting a bug report, please provide as much information as you can, including clear and concise repro steps, attaching any necessary -files to assist in the repro. **Be sure to scrub the files of any potentially sensitive information. Issues are public.** - -For a feature request, please try to describe the scenario you are trying to accomplish that requires the feature. This will help us understand -the limitations that you are running into, and provide us with a use case to know if we've satisfied your request. - -### Making Contributions - -Refer to the [Developer Guide](https://tableau.github.io/server-client-python/docs/dev-guide) which explains how to make contributions to the TSC project. +# Contributing + +We welcome contributions to this project! + +Contribution can include, but are not limited to, any of the following: + +* File an Issue +* Request a Feature +* Implement a Requested Feature +* Fix an Issue/Bug +* Add/Fix documentation + +## Issues and Feature Requests + +To submit an issue/bug report, or to request a feature, please submit a [GitHub issue](https://github.com/tableau/server-client-python/issues) to the repo. + +If you are submitting a bug report, please provide as much information as you can, including clear and concise repro steps, attaching any necessary +files to assist in the repro. **Be sure to scrub the files of any potentially sensitive information. Issues are public.** + +For a feature request, please try to describe the scenario you are trying to accomplish that requires the feature. This will help us understand +the limitations that you are running into, and provide us with a use case to know if we've satisfied your request. + +### Making Contributions + +Refer to the [Developer Guide](https://tableau.github.io/server-client-python/docs/dev-guide) which explains how to make contributions to the TSC project. + +## Running Tests + +### Unit tests + +```bash +pip install -e ".[test]" +pytest +``` + +### End-to-end tests + +E2e tests run against a real Tableau server and are kept in `test_e2e/`. They are excluded from the default `pytest` run. + +**Required environment variables:** + +| Variable | Description | +|---|---| +| `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_PROJECT` | Project to publish test content into (defaults to `Default`) | + +**Run:** + +```bash +TABLEAU_SERVER=https://... TABLEAU_SITE=mysite TABLEAU_TOKEN=... TABLEAU_TOKEN_NAME=mytoken TABLEAU_PROJECT="My Project" \ +pytest test_e2e/ +``` diff --git a/pyproject.toml b/pyproject.toml index ebb4fe8be..66a1e336b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,7 @@ exclude = ['/bin/'] [tool.pytest.ini_options] testpaths = ["test"] addopts = "--junitxml=./test.junit.xml" +markers = ["e2e: mark test as end-to-end (requires a real Tableau server)"] [tool.versioneer] VCS = "git" diff --git a/test_e2e/__init__.py b/test_e2e/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test_e2e/assets/WorkbookWithoutExtract.twbx b/test_e2e/assets/WorkbookWithoutExtract.twbx new file mode 100644 index 0000000000000000000000000000000000000000..49fc684af5208df28a07b79ec2ce41d9b2279985 GIT binary patch literal 20782 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 literal 0 HcmV?d00001 diff --git a/test_e2e/conftest.py b/test_e2e/conftest.py new file mode 100644 index 000000000..376d41a09 --- /dev/null +++ b/test_e2e/conftest.py @@ -0,0 +1,32 @@ +import os +import pytest +import tableauserverclient as TSC + + +def pytest_configure(config): + config.addinivalue_line("markers", "e2e: mark test as end-to-end (requires a real Tableau server)") + + +@pytest.fixture(scope="session") +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 + """ + url = os.environ.get("TABLEAU_SERVER") + site = os.environ.get("TABLEAU_SITE", "") + token = os.environ.get("TABLEAU_TOKEN") + token_name = os.environ.get("TABLEAU_TOKEN_NAME") + + if not all([url, token, token_name]): + pytest.skip("E2E tests require TABLEAU_SERVER, TABLEAU_TOKEN, and TABLEAU_TOKEN_NAME env vars") + + server = TSC.Server(url, use_server_version=True) + auth = TSC.PersonalAccessTokenAuth(token_name, token, site) + with server.auth.sign_in(auth): + yield server diff --git a/test_e2e/test_tagging.py b/test_e2e/test_tagging.py new file mode 100644 index 000000000..4af741255 --- /dev/null +++ b/test_e2e/test_tagging.py @@ -0,0 +1,78 @@ +""" +E2E tests for tag operations against a real Tableau server. + +Run with: + 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 +import tableauserverclient as TSC + +ASSETS_DIR = Path(__file__).parent / "assets" +SAMPLE_WORKBOOK = ASSETS_DIR / "WorkbookWithoutExtract.twbx" + +pytestmark = pytest.mark.e2e + + +@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) + wb = server.workbooks.publish(wb, SAMPLE_WORKBOOK, TSC.Server.PublishMode.Overwrite) + yield wb + server.workbooks.delete(wb.id) + + +def test_tag_with_spaces_stored_as_single_tag(server, workbook): + """A tag containing a space must be stored as one tag, not split on the space.""" + spaced_tag = "Yearly Sales" + server.workbooks.add_tags(workbook, spaced_tag) + updated = server.workbooks.get_by_id(workbook.id) + try: + assert spaced_tag in updated.tags, ( + f"Tag '{spaced_tag}' not found in {updated.tags!r} — was it split on the space?" + ) + assert "Yearly" not in updated.tags, "Tag was incorrectly split — 'Yearly' should not be a separate tag" + assert "Sales" not in updated.tags, "Tag was incorrectly split — 'Sales' should not be a separate tag" + finally: + server.workbooks.delete_tags(workbook, spaced_tag) + + +def test_tag_with_comma_stored_as_single_tag(server, workbook): + """A tag containing a comma must be stored as one tag, not split on the comma.""" + comma_tag = "Sales,Marketing" + server.workbooks.add_tags(workbook, comma_tag) + updated = server.workbooks.get_by_id(workbook.id) + try: + assert comma_tag in updated.tags, ( + f"Tag '{comma_tag}' not found in {updated.tags!r} — was it split on the comma?" + ) + finally: + server.workbooks.delete_tags(workbook, comma_tag) + + +def test_multiple_tags_including_spaced(server, workbook): + """Adding multiple tags where one has a space should all round-trip correctly.""" + tags = ["simple", "Yearly Sales", "another tag"] + server.workbooks.add_tags(workbook, tags) + updated = server.workbooks.get_by_id(workbook.id) + try: + for tag in tags: + assert tag in updated.tags, f"Tag '{tag}' not found in {updated.tags!r}" + finally: + server.workbooks.delete_tags(workbook, tags) From 04133e7a37f09ad6245cb799e88db16634424edd Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Wed, 17 Jun 2026 20:52:44 -0700 Subject: [PATCH 3/3] fix: encode special characters in tag delete URL path Fixes #675 #994 Co-Authored-By: Claude Sonnet 4.6 --- .../server/endpoint/resource_tagger.py | 4 ++-- test/test_tagging.py | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 705d33441..72c7274c8 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -39,7 +39,7 @@ def _add_tags(self, baseurl, resource_id, tag_set): # Delete a resource's tag by name def _delete_tag(self, baseurl, resource_id, tag_name): - encoded_tag_name = urllib.parse.quote(tag_name) + encoded_tag_name = urllib.parse.quote(tag_name, safe="") url = f"{baseurl}/{resource_id}/tags/{encoded_tag_name}" try: @@ -124,7 +124,7 @@ def delete_tags(self, item: T | str, tags: Iterable[str] | str) -> None: tag_set = set(tags) for tag in tag_set: - encoded_tag_name = urllib.parse.quote(tag) + encoded_tag_name = urllib.parse.quote(tag, safe="") url = f"{self.baseurl}/{item_id}/tags/{encoded_tag_name}" self.delete_request(url) diff --git a/test/test_tagging.py b/test/test_tagging.py index c8fbe142a..87f444fce 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -256,3 +256,27 @@ def test_tag_with_spaces_is_quoted_in_request() -> None: labels = {tag.get("label") for tag in root.findall(".//tag")} assert '"Yearly Sales"' in labels assert "simple" in labels + + +@pytest.mark.parametrize( + "tag, expected_encoded", + [ + ("tag#name", "tag%23name"), # issue #675: hash must be percent-encoded + ("tag.name", "tag.name"), # issue #994: dot is safe, no encoding needed + ("tag+name", "tag%2Bname"), # plus must be percent-encoded + ("tag/name", "tag%2Fname"), # slash must be percent-encoded (safe='' fix) + ("tag name", "tag%20name"), # space must be percent-encoded + ], +) +def test_delete_tags_special_characters_encoded(get_server, tag, expected_encoded) -> None: + """Verify delete_tags percent-encodes special characters in the tag path segment.""" + server = get_server + workbook = make_workbook() + + with requests_mock.mock() as m: + m.delete(requests_mock.ANY, status_code=200) + server.workbooks.delete_tags(workbook, tag) + history = m.request_history + + assert len(history) == 1 + assert history[0].url.endswith(f"/tags/{expected_encoded}")