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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@

## Unreleased

* Added `Projects.get_by_path(path)` to look up a project by its slash-separated
hierarchy path (e.g. `"Marketing/Q1 Reports"`). The walk is performed level by
level using the REST API name filter, so a path with *n* components issues *n*
requests. Returns the matching `ProjectItem` or `None` if no project is found.

## 0.18.0 (6 April 2022)
* Switched to using defused_xml for xml attack protection
* added linting and type hints
Expand Down
71 changes: 71 additions & 0 deletions tableauserverclient/server/endpoint/projects_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError
from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint
from tableauserverclient.server import RequestFactory, RequestOptions
from tableauserverclient.server.filter import Filter
from tableauserverclient.models.permissions_item import PermissionsRule
from tableauserverclient.models import ProjectItem, PaginationItem, Resource

Expand Down Expand Up @@ -89,6 +90,76 @@ def delete(self, project_id: str) -> None:
self.delete_request(url)
logger.info(f"Deleted single project (ID: {project_id})")

@api(version="2.0")
def get_by_path(self, path: str) -> "ProjectItem | None":
"""
Retrieves a project by its path. The path is a slash-separated string
of project names from the root to the target project, for example
``"Marketing/Q1 Reports"`` or ``"/Marketing/Q1 Reports"``.

There is no native path filter in the Tableau REST API, so this method
walks the project hierarchy level by level using ``filter(name=...)``.
Each level makes one API request, so a path with *n* components issues
*n* requests.

At the root level, the API ``filter(name=...)`` may return projects from
different levels of the hierarchy that share the same name. Only
projects with no parent (``parentProjectId`` absent) are considered at
this step. For subsequent levels, the ``parentProjectId`` filter is sent
to the server so only direct children of the current project are
returned.

If multiple sibling projects share the same name at any level, the
first project returned by the API is used and the rest are ignored.

Parameters
----------
path : str
The slash-separated path of the project. Leading and trailing
slashes are ignored. Empty components (e.g. from consecutive
slashes) are discarded.

Returns
-------
ProjectItem | None
The matching project, or ``None`` if no project exists at the
given path.

Raises
------
ValueError
If ``path`` is empty or contains only slashes.
"""
components = [c for c in path.split("/") if c]
if not components:
raise ValueError("Project path must not be empty.")

# Walk the hierarchy one level at a time.
parent_id: "str | None" = None
current: "ProjectItem | None" = None

for name in components:
opts = RequestOptions()
opts.filter.add(Filter(RequestOptions.Field.Name, RequestOptions.Operator.Equals, name))
if parent_id is not None:
opts.filter.add(Filter(RequestOptions.Field.ParentProjectId, RequestOptions.Operator.Equals, parent_id))

projects, _ = self.get(opts)

if parent_id is None:
# At the root level the API may return projects with the same
# name that belong to different parents; keep only top-level ones.
projects = [p for p in projects if p.parent_id is None]

if not projects:
return None

# If multiple sibling projects share the same name, take the first.
current = projects[0]
parent_id = current.id

return current

@api(version="2.0")
def get_by_id(self, project_id: str) -> ProjectItem:
"""
Expand Down
8 changes: 8 additions & 0 deletions test/assets/project_get_by_name_ambiguous_root.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version='1.0' encoding='UTF-8'?>
<tsResponse xmlns="http://tableau.com/api" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-2.3.xsd">
<pagination pageNumber="1" pageSize="100" totalAvailable="2" />
<projects>
<project id="aaaaaaaa-0000-0000-0000-000000000001" name="Shared" description="" contentPermissions="ManagedByOwner" parentProjectId="1d0304cd-3796-429f-b815-7258370b9b74"><owner id="dd2239f6-ddf1-4107-981a-4cf94e415794" /></project>
<project id="bbbbbbbb-0000-0000-0000-000000000002" name="Shared" description="" contentPermissions="ManagedByOwner" topLevelProject="true"><owner id="2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3" /></project>
</projects>
</tsResponse>
7 changes: 7 additions & 0 deletions test/assets/project_get_by_name_child.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='UTF-8'?>
<tsResponse xmlns="http://tableau.com/api" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-2.3.xsd">
<pagination pageNumber="1" pageSize="100" totalAvailable="1" />
<projects>
<project id="4cc52973-5e3a-4d1f-a4fb-5b5f73796edf" name="Child 1" description="" contentPermissions="ManagedByOwner" parentProjectId="1d0304cd-3796-429f-b815-7258370b9b74"><owner id="dd2239f6-ddf1-4107-981a-4cf94e415794" /></project>
</projects>
</tsResponse>
8 changes: 8 additions & 0 deletions test/assets/project_get_by_name_duplicate_siblings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version='1.0' encoding='UTF-8'?>
<tsResponse xmlns="http://tableau.com/api" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-2.3.xsd">
<pagination pageNumber="1" pageSize="100" totalAvailable="2" />
<projects>
<project id="cccccccc-0000-0000-0000-000000000001" name="Reports" description="" contentPermissions="ManagedByOwner" parentProjectId="1d0304cd-3796-429f-b815-7258370b9b74"><owner id="dd2239f6-ddf1-4107-981a-4cf94e415794" /></project>
<project id="dddddddd-0000-0000-0000-000000000002" name="Reports" description="" contentPermissions="ManagedByOwner" parentProjectId="1d0304cd-3796-429f-b815-7258370b9b74"><owner id="dd2239f6-ddf1-4107-981a-4cf94e415794" /></project>
</projects>
</tsResponse>
7 changes: 7 additions & 0 deletions test/assets/project_get_by_name_grandchild.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='UTF-8'?>
<tsResponse xmlns="http://tableau.com/api" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-2.3.xsd">
<pagination pageNumber="1" pageSize="100" totalAvailable="1" />
<projects>
<project id="eeeeeeee-0000-0000-0000-000000000001" name="Q1" description="" contentPermissions="ManagedByOwner" parentProjectId="4cc52973-5e3a-4d1f-a4fb-5b5f73796edf"><owner id="dd2239f6-ddf1-4107-981a-4cf94e415794" /></project>
</projects>
</tsResponse>
7 changes: 7 additions & 0 deletions test/assets/project_get_by_name_top_level.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='UTF-8'?>
<tsResponse xmlns="http://tableau.com/api" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-2.3.xsd">
<pagination pageNumber="1" pageSize="100" totalAvailable="1" />
<projects>
<project id="1d0304cd-3796-429f-b815-7258370b9b74" name="Tableau" description="" contentPermissions="ManagedByOwner" topLevelProject="true"><owner id="2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3" /></project>
</projects>
</tsResponse>
6 changes: 6 additions & 0 deletions test/assets/project_get_empty.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version='1.0' encoding='UTF-8'?>
<tsResponse xmlns="http://tableau.com/api" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-2.3.xsd">
<pagination pageNumber="1" pageSize="100" totalAvailable="0" />
<projects>
</projects>
</tsResponse>
233 changes: 233 additions & 0 deletions test/test_project.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from pathlib import Path
from urllib.parse import parse_qs, urlparse

import pytest
import requests_mock
Expand All @@ -22,6 +23,12 @@
UPDATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML = (
TEST_ASSET_DIR / "project_update_virtualconnection_default_permissions.xml"
)
GET_BY_NAME_TOP_LEVEL_XML = TEST_ASSET_DIR / "project_get_by_name_top_level.xml"
GET_BY_NAME_CHILD_XML = TEST_ASSET_DIR / "project_get_by_name_child.xml"
GET_EMPTY_XML = TEST_ASSET_DIR / "project_get_empty.xml"
GET_BY_NAME_AMBIGUOUS_ROOT_XML = TEST_ASSET_DIR / "project_get_by_name_ambiguous_root.xml"
GET_BY_NAME_DUPLICATE_SIBLINGS_XML = TEST_ASSET_DIR / "project_get_by_name_duplicate_siblings.xml"
GET_BY_NAME_GRANDCHILD_XML = TEST_ASSET_DIR / "project_get_by_name_grandchild.xml"


@pytest.fixture(scope="function")
Expand Down Expand Up @@ -464,3 +471,229 @@ def test_get_all_fields(server: TSC.Server) -> None:
assert project.content_permissions == "ManagedByOwner"
assert project.parent_id is None
assert project.writeable is True


# --- get_by_path tests ---


def test_get_by_path_top_level(server: TSC.Server) -> None:
"""A single-component path resolves to a top-level project."""
response_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
with requests_mock.mock() as m:
m.get(server.projects.baseurl + "?filter=name:eq:Tableau", text=response_xml)
project = server.projects.get_by_path("Tableau")

assert project is not None
assert project.id == "1d0304cd-3796-429f-b815-7258370b9b74"
assert project.name == "Tableau"
assert project.parent_id is None


def test_get_by_path_top_level_with_leading_slash(server: TSC.Server) -> None:
"""Leading slash is stripped; result is the same as without it."""
response_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
with requests_mock.mock() as m:
m.get(server.projects.baseurl + "?filter=name:eq:Tableau", text=response_xml)
project = server.projects.get_by_path("/Tableau")

assert project is not None
assert project.id == "1d0304cd-3796-429f-b815-7258370b9b74"


def _filter_params(request) -> dict:
"""Parse the comma-separated 'filter' query param into a dict of field:value pairs."""
qs = parse_qs(urlparse(request.url).query)
filters = {}
for token in qs.get("filter", [""])[0].split(","):
if ":" in token:
parts = token.split(":", 2) # field, operator, value
if len(parts) == 3:
filters[parts[0]] = parts[2]
return filters


def test_get_by_path_nested(server: TSC.Server) -> None:
"""A two-component path walks the hierarchy and returns the child project."""
top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
child_xml = GET_BY_NAME_CHILD_XML.read_text()
baseurl = server.projects.baseurl

def respond(request, context):
params = _filter_params(request)
if params.get("name") == "Tableau" and "parentProjectId" not in params:
return top_level_xml
if params.get("name") == "Child 1" and params.get("parentProjectId") == "1d0304cd-3796-429f-b815-7258370b9b74":
return child_xml
context.status_code = 404
return ""

with requests_mock.mock() as m:
m.get(baseurl, text=respond)
project = server.projects.get_by_path("Tableau/Child 1")

assert project is not None
assert project.id == "4cc52973-5e3a-4d1f-a4fb-5b5f73796edf"
assert project.name == "Child 1"
assert project.parent_id == "1d0304cd-3796-429f-b815-7258370b9b74"


def test_get_by_path_not_found_root(server: TSC.Server) -> None:
"""Returns None when the root-level component does not exist."""
empty_xml = GET_EMPTY_XML.read_text()
baseurl = server.projects.baseurl

def respond(request, context):
params = _filter_params(request)
if params.get("name") == "NonExistent":
return empty_xml
context.status_code = 404
return ""

with requests_mock.mock() as m:
m.get(baseurl, text=respond)
project = server.projects.get_by_path("NonExistent")

assert project is None


def test_get_by_path_not_found_child(server: TSC.Server) -> None:
"""Returns None when a child component does not exist under the parent."""
top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
empty_xml = GET_EMPTY_XML.read_text()
baseurl = server.projects.baseurl

def respond(request, context):
params = _filter_params(request)
if params.get("name") == "Tableau" and "parentProjectId" not in params:
return top_level_xml
if params.get("name") == "NoSuchChild":
return empty_xml
context.status_code = 404
return ""

with requests_mock.mock() as m:
m.get(baseurl, text=respond)
project = server.projects.get_by_path("Tableau/NoSuchChild")

assert project is None


def test_get_by_path_empty_raises(server: TSC.Server) -> None:
"""An empty or slash-only path raises ValueError."""
with pytest.raises(ValueError):
server.projects.get_by_path("")

with pytest.raises(ValueError):
server.projects.get_by_path("/")


def test_get_by_path_trailing_slash(server: TSC.Server) -> None:
"""Trailing slash is stripped; result is the same as the bare name."""
response_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
with requests_mock.mock() as m:
m.get(server.projects.baseurl + "?filter=name:eq:Tableau", text=response_xml)
project = server.projects.get_by_path("Tableau/")

assert project is not None
assert project.id == "1d0304cd-3796-429f-b815-7258370b9b74"


def test_get_by_path_root_filters_non_top_level(server: TSC.Server) -> None:
"""When the API returns projects with the same name at different levels,
get_by_path keeps only the one with no parent when looking at the root."""
ambiguous_xml = GET_BY_NAME_AMBIGUOUS_ROOT_XML.read_text()
baseurl = server.projects.baseurl

def respond(request, context):
params = _filter_params(request)
if params.get("name") == "Shared" and "parentProjectId" not in params:
return ambiguous_xml
context.status_code = 404
return ""

with requests_mock.mock() as m:
m.get(baseurl, text=respond)
project = server.projects.get_by_path("Shared")

# The implementation filters to parent_id is None at the root level, so
# only "bbbbbbbb-..." (the actual top-level project) should be returned.
assert project is not None
assert project.id == "bbbbbbbb-0000-0000-0000-000000000002"
assert project.parent_id is None


def test_get_by_path_duplicate_siblings_returns_first(server: TSC.Server) -> None:
"""When multiple sibling projects share the same name, the first one is returned."""
top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
duplicate_xml = GET_BY_NAME_DUPLICATE_SIBLINGS_XML.read_text()
baseurl = server.projects.baseurl

def respond(request, context):
params = _filter_params(request)
if params.get("name") == "Tableau" and "parentProjectId" not in params:
return top_level_xml
if params.get("name") == "Reports" and params.get("parentProjectId") == "1d0304cd-3796-429f-b815-7258370b9b74":
return duplicate_xml
context.status_code = 404
return ""

with requests_mock.mock() as m:
m.get(baseurl, text=respond)
project = server.projects.get_by_path("Tableau/Reports")

# Duplicate siblings: implementation takes the first result from the API.
assert project is not None
assert project.id == "cccccccc-0000-0000-0000-000000000001"


def test_get_by_path_deep_three_levels(server: TSC.Server) -> None:
"""A three-component path issues three requests and returns the deepest project."""
top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
child_xml = GET_BY_NAME_CHILD_XML.read_text()
grandchild_xml = GET_BY_NAME_GRANDCHILD_XML.read_text()
baseurl = server.projects.baseurl

def respond(request, context):
params = _filter_params(request)
if params.get("name") == "Tableau" and "parentProjectId" not in params:
return top_level_xml
if params.get("name") == "Child 1" and params.get("parentProjectId") == "1d0304cd-3796-429f-b815-7258370b9b74":
return child_xml
if params.get("name") == "Q1" and params.get("parentProjectId") == "4cc52973-5e3a-4d1f-a4fb-5b5f73796edf":
return grandchild_xml
context.status_code = 404
return ""

with requests_mock.mock() as m:
m.get(baseurl, text=respond)
project = server.projects.get_by_path("Tableau/Child 1/Q1")

assert project is not None
assert project.id == "eeeeeeee-0000-0000-0000-000000000001"
assert project.name == "Q1"
assert project.parent_id == "4cc52973-5e3a-4d1f-a4fb-5b5f73796edf"


def test_get_by_path_with_spaces_in_name(server: TSC.Server) -> None:
"""Project names containing spaces are handled correctly."""
# Reuses the child fixture whose name is 'Child 1' (contains a space).
top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
child_xml = GET_BY_NAME_CHILD_XML.read_text()
baseurl = server.projects.baseurl

def respond(request, context):
params = _filter_params(request)
if params.get("name") == "Tableau" and "parentProjectId" not in params:
return top_level_xml
if params.get("name") == "Child 1" and params.get("parentProjectId") == "1d0304cd-3796-429f-b815-7258370b9b74":
return child_xml
context.status_code = 404
return ""

with requests_mock.mock() as m:
m.get(baseurl, text=respond)
project = server.projects.get_by_path("Tableau/Child 1")

assert project is not None
assert project.id == "4cc52973-5e3a-4d1f-a4fb-5b5f73796edf"
assert project.name == "Child 1"
Loading