Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 55 additions & 25 deletions contributing.md
Original file line number Diff line number Diff line change
@@ -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/
```
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions tableauserverclient/server/endpoint/resource_tagger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down
12 changes: 10 additions & 2 deletions tableauserverclient/server/request_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down
40 changes: 39 additions & 1 deletion test/test_tagging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -242,3 +244,39 @@ 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


@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}")
Empty file added test_e2e/__init__.py
Empty file.
Binary file added test_e2e/assets/WorkbookWithoutExtract.twbx
Binary file not shown.
32 changes: 32 additions & 0 deletions test_e2e/conftest.py
Original file line number Diff line number Diff line change
@@ -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
78 changes: 78 additions & 0 deletions test_e2e/test_tagging.py
Original file line number Diff line number Diff line change
@@ -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)
Loading