diff --git a/CHANGELOG.md b/CHANGELOG.md
index c018294d3..943436b27 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py
index f63110592..edc6ef670 100644
--- a/tableauserverclient/server/endpoint/projects_endpoint.py
+++ b/tableauserverclient/server/endpoint/projects_endpoint.py
@@ -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
@@ -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:
"""
diff --git a/test/assets/project_get_by_name_ambiguous_root.xml b/test/assets/project_get_by_name_ambiguous_root.xml
new file mode 100644
index 000000000..b2b65ed64
--- /dev/null
+++ b/test/assets/project_get_by_name_ambiguous_root.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/test/assets/project_get_by_name_child.xml b/test/assets/project_get_by_name_child.xml
new file mode 100644
index 000000000..2ce185c1d
--- /dev/null
+++ b/test/assets/project_get_by_name_child.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/test/assets/project_get_by_name_duplicate_siblings.xml b/test/assets/project_get_by_name_duplicate_siblings.xml
new file mode 100644
index 000000000..68f73b3bd
--- /dev/null
+++ b/test/assets/project_get_by_name_duplicate_siblings.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/test/assets/project_get_by_name_grandchild.xml b/test/assets/project_get_by_name_grandchild.xml
new file mode 100644
index 000000000..70ef855c0
--- /dev/null
+++ b/test/assets/project_get_by_name_grandchild.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/test/assets/project_get_by_name_top_level.xml b/test/assets/project_get_by_name_top_level.xml
new file mode 100644
index 000000000..df7cdb38e
--- /dev/null
+++ b/test/assets/project_get_by_name_top_level.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/test/assets/project_get_empty.xml b/test/assets/project_get_empty.xml
new file mode 100644
index 000000000..d8c206124
--- /dev/null
+++ b/test/assets/project_get_empty.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/test/test_project.py b/test/test_project.py
index eb33f6732..839838101 100644
--- a/test/test_project.py
+++ b/test/test_project.py
@@ -1,4 +1,5 @@
from pathlib import Path
+from urllib.parse import parse_qs, urlparse
import pytest
import requests_mock
@@ -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")
@@ -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"