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/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/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..87f444fce 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,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}") 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 000000000..49fc684af Binary files /dev/null and b/test_e2e/assets/WorkbookWithoutExtract.twbx differ 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)