Skip to content

Commit af1d77d

Browse files
jacalataclaude
andauthored
Add Projects.get_by_path() for hierarchy-aware project lookup (#1804)
* feat: add get_by_path to projects endpoint Fixes #1317 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: expand get_by_path coverage and sharpen docstring Add test cases for trailing slash, root-level disambiguation (API returns same-named projects at different hierarchy levels), duplicate siblings (first result is used), deep three-level paths, and names containing spaces. Add three new XML fixture files to support these scenarios. Improve the get_by_path docstring to document the client-side root-level parentProjectId filter, the duplicate-name tie-break, and the empty-component-stripping behavior. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add CHANGELOG entry for Projects.get_by_path Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 94ab761 commit af1d77d

9 files changed

Lines changed: 354 additions & 0 deletions

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11

2+
## Unreleased
3+
4+
* Added `Projects.get_by_path(path)` to look up a project by its slash-separated
5+
hierarchy path (e.g. `"Marketing/Q1 Reports"`). The walk is performed level by
6+
level using the REST API name filter, so a path with *n* components issues *n*
7+
requests. Returns the matching `ProjectItem` or `None` if no project is found.
8+
29
## 0.18.0 (6 April 2022)
310
* Switched to using defused_xml for xml attack protection
411
* added linting and type hints

‎tableauserverclient/server/endpoint/projects_endpoint.py‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError
66
from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint
77
from tableauserverclient.server import RequestFactory, RequestOptions
8+
from tableauserverclient.server.filter import Filter
89
from tableauserverclient.models.permissions_item import PermissionsRule
910
from tableauserverclient.models import ProjectItem, PaginationItem, Resource
1011

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

93+
@api(version="2.0")
94+
def get_by_path(self, path: str) -> "ProjectItem | None":
95+
"""
96+
Retrieves a project by its path. The path is a slash-separated string
97+
of project names from the root to the target project, for example
98+
``"Marketing/Q1 Reports"`` or ``"/Marketing/Q1 Reports"``.
99+
100+
There is no native path filter in the Tableau REST API, so this method
101+
walks the project hierarchy level by level using ``filter(name=...)``.
102+
Each level makes one API request, so a path with *n* components issues
103+
*n* requests.
104+
105+
At the root level, the API ``filter(name=...)`` may return projects from
106+
different levels of the hierarchy that share the same name. Only
107+
projects with no parent (``parentProjectId`` absent) are considered at
108+
this step. For subsequent levels, the ``parentProjectId`` filter is sent
109+
to the server so only direct children of the current project are
110+
returned.
111+
112+
If multiple sibling projects share the same name at any level, the
113+
first project returned by the API is used and the rest are ignored.
114+
115+
Parameters
116+
----------
117+
path : str
118+
The slash-separated path of the project. Leading and trailing
119+
slashes are ignored. Empty components (e.g. from consecutive
120+
slashes) are discarded.
121+
122+
Returns
123+
-------
124+
ProjectItem | None
125+
The matching project, or ``None`` if no project exists at the
126+
given path.
127+
128+
Raises
129+
------
130+
ValueError
131+
If ``path`` is empty or contains only slashes.
132+
"""
133+
components = [c for c in path.split("/") if c]
134+
if not components:
135+
raise ValueError("Project path must not be empty.")
136+
137+
# Walk the hierarchy one level at a time.
138+
parent_id: "str | None" = None
139+
current: "ProjectItem | None" = None
140+
141+
for name in components:
142+
opts = RequestOptions()
143+
opts.filter.add(Filter(RequestOptions.Field.Name, RequestOptions.Operator.Equals, name))
144+
if parent_id is not None:
145+
opts.filter.add(Filter(RequestOptions.Field.ParentProjectId, RequestOptions.Operator.Equals, parent_id))
146+
147+
projects, _ = self.get(opts)
148+
149+
if parent_id is None:
150+
# At the root level the API may return projects with the same
151+
# name that belong to different parents; keep only top-level ones.
152+
projects = [p for p in projects if p.parent_id is None]
153+
154+
if not projects:
155+
return None
156+
157+
# If multiple sibling projects share the same name, take the first.
158+
current = projects[0]
159+
parent_id = current.id
160+
161+
return current
162+
92163
@api(version="2.0")
93164
def get_by_id(self, project_id: str) -> ProjectItem:
94165
"""
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?xml version='1.0' encoding='UTF-8'?>
2+
<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">
3+
<pagination pageNumber="1" pageSize="100" totalAvailable="2" />
4+
<projects>
5+
<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>
6+
<project id="bbbbbbbb-0000-0000-0000-000000000002" name="Shared" description="" contentPermissions="ManagedByOwner" topLevelProject="true"><owner id="2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3" /></project>
7+
</projects>
8+
</tsResponse>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?xml version='1.0' encoding='UTF-8'?>
2+
<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">
3+
<pagination pageNumber="1" pageSize="100" totalAvailable="1" />
4+
<projects>
5+
<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>
6+
</projects>
7+
</tsResponse>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?xml version='1.0' encoding='UTF-8'?>
2+
<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">
3+
<pagination pageNumber="1" pageSize="100" totalAvailable="2" />
4+
<projects>
5+
<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>
6+
<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>
7+
</projects>
8+
</tsResponse>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?xml version='1.0' encoding='UTF-8'?>
2+
<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">
3+
<pagination pageNumber="1" pageSize="100" totalAvailable="1" />
4+
<projects>
5+
<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>
6+
</projects>
7+
</tsResponse>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?xml version='1.0' encoding='UTF-8'?>
2+
<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">
3+
<pagination pageNumber="1" pageSize="100" totalAvailable="1" />
4+
<projects>
5+
<project id="1d0304cd-3796-429f-b815-7258370b9b74" name="Tableau" description="" contentPermissions="ManagedByOwner" topLevelProject="true"><owner id="2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3" /></project>
6+
</projects>
7+
</tsResponse>

‎test/assets/project_get_empty.xml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<?xml version='1.0' encoding='UTF-8'?>
2+
<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">
3+
<pagination pageNumber="1" pageSize="100" totalAvailable="0" />
4+
<projects>
5+
</projects>
6+
</tsResponse>

‎test/test_project.py‎

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from pathlib import Path
2+
from urllib.parse import parse_qs, urlparse
23

34
import pytest
45
import requests_mock
@@ -22,6 +23,12 @@
2223
UPDATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML = (
2324
TEST_ASSET_DIR / "project_update_virtualconnection_default_permissions.xml"
2425
)
26+
GET_BY_NAME_TOP_LEVEL_XML = TEST_ASSET_DIR / "project_get_by_name_top_level.xml"
27+
GET_BY_NAME_CHILD_XML = TEST_ASSET_DIR / "project_get_by_name_child.xml"
28+
GET_EMPTY_XML = TEST_ASSET_DIR / "project_get_empty.xml"
29+
GET_BY_NAME_AMBIGUOUS_ROOT_XML = TEST_ASSET_DIR / "project_get_by_name_ambiguous_root.xml"
30+
GET_BY_NAME_DUPLICATE_SIBLINGS_XML = TEST_ASSET_DIR / "project_get_by_name_duplicate_siblings.xml"
31+
GET_BY_NAME_GRANDCHILD_XML = TEST_ASSET_DIR / "project_get_by_name_grandchild.xml"
2532

2633

2734
@pytest.fixture(scope="function")
@@ -464,3 +471,229 @@ def test_get_all_fields(server: TSC.Server) -> None:
464471
assert project.content_permissions == "ManagedByOwner"
465472
assert project.parent_id is None
466473
assert project.writeable is True
474+
475+
476+
# --- get_by_path tests ---
477+
478+
479+
def test_get_by_path_top_level(server: TSC.Server) -> None:
480+
"""A single-component path resolves to a top-level project."""
481+
response_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
482+
with requests_mock.mock() as m:
483+
m.get(server.projects.baseurl + "?filter=name:eq:Tableau", text=response_xml)
484+
project = server.projects.get_by_path("Tableau")
485+
486+
assert project is not None
487+
assert project.id == "1d0304cd-3796-429f-b815-7258370b9b74"
488+
assert project.name == "Tableau"
489+
assert project.parent_id is None
490+
491+
492+
def test_get_by_path_top_level_with_leading_slash(server: TSC.Server) -> None:
493+
"""Leading slash is stripped; result is the same as without it."""
494+
response_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
495+
with requests_mock.mock() as m:
496+
m.get(server.projects.baseurl + "?filter=name:eq:Tableau", text=response_xml)
497+
project = server.projects.get_by_path("/Tableau")
498+
499+
assert project is not None
500+
assert project.id == "1d0304cd-3796-429f-b815-7258370b9b74"
501+
502+
503+
def _filter_params(request) -> dict:
504+
"""Parse the comma-separated 'filter' query param into a dict of field:value pairs."""
505+
qs = parse_qs(urlparse(request.url).query)
506+
filters = {}
507+
for token in qs.get("filter", [""])[0].split(","):
508+
if ":" in token:
509+
parts = token.split(":", 2) # field, operator, value
510+
if len(parts) == 3:
511+
filters[parts[0]] = parts[2]
512+
return filters
513+
514+
515+
def test_get_by_path_nested(server: TSC.Server) -> None:
516+
"""A two-component path walks the hierarchy and returns the child project."""
517+
top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
518+
child_xml = GET_BY_NAME_CHILD_XML.read_text()
519+
baseurl = server.projects.baseurl
520+
521+
def respond(request, context):
522+
params = _filter_params(request)
523+
if params.get("name") == "Tableau" and "parentProjectId" not in params:
524+
return top_level_xml
525+
if params.get("name") == "Child 1" and params.get("parentProjectId") == "1d0304cd-3796-429f-b815-7258370b9b74":
526+
return child_xml
527+
context.status_code = 404
528+
return ""
529+
530+
with requests_mock.mock() as m:
531+
m.get(baseurl, text=respond)
532+
project = server.projects.get_by_path("Tableau/Child 1")
533+
534+
assert project is not None
535+
assert project.id == "4cc52973-5e3a-4d1f-a4fb-5b5f73796edf"
536+
assert project.name == "Child 1"
537+
assert project.parent_id == "1d0304cd-3796-429f-b815-7258370b9b74"
538+
539+
540+
def test_get_by_path_not_found_root(server: TSC.Server) -> None:
541+
"""Returns None when the root-level component does not exist."""
542+
empty_xml = GET_EMPTY_XML.read_text()
543+
baseurl = server.projects.baseurl
544+
545+
def respond(request, context):
546+
params = _filter_params(request)
547+
if params.get("name") == "NonExistent":
548+
return empty_xml
549+
context.status_code = 404
550+
return ""
551+
552+
with requests_mock.mock() as m:
553+
m.get(baseurl, text=respond)
554+
project = server.projects.get_by_path("NonExistent")
555+
556+
assert project is None
557+
558+
559+
def test_get_by_path_not_found_child(server: TSC.Server) -> None:
560+
"""Returns None when a child component does not exist under the parent."""
561+
top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
562+
empty_xml = GET_EMPTY_XML.read_text()
563+
baseurl = server.projects.baseurl
564+
565+
def respond(request, context):
566+
params = _filter_params(request)
567+
if params.get("name") == "Tableau" and "parentProjectId" not in params:
568+
return top_level_xml
569+
if params.get("name") == "NoSuchChild":
570+
return empty_xml
571+
context.status_code = 404
572+
return ""
573+
574+
with requests_mock.mock() as m:
575+
m.get(baseurl, text=respond)
576+
project = server.projects.get_by_path("Tableau/NoSuchChild")
577+
578+
assert project is None
579+
580+
581+
def test_get_by_path_empty_raises(server: TSC.Server) -> None:
582+
"""An empty or slash-only path raises ValueError."""
583+
with pytest.raises(ValueError):
584+
server.projects.get_by_path("")
585+
586+
with pytest.raises(ValueError):
587+
server.projects.get_by_path("/")
588+
589+
590+
def test_get_by_path_trailing_slash(server: TSC.Server) -> None:
591+
"""Trailing slash is stripped; result is the same as the bare name."""
592+
response_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
593+
with requests_mock.mock() as m:
594+
m.get(server.projects.baseurl + "?filter=name:eq:Tableau", text=response_xml)
595+
project = server.projects.get_by_path("Tableau/")
596+
597+
assert project is not None
598+
assert project.id == "1d0304cd-3796-429f-b815-7258370b9b74"
599+
600+
601+
def test_get_by_path_root_filters_non_top_level(server: TSC.Server) -> None:
602+
"""When the API returns projects with the same name at different levels,
603+
get_by_path keeps only the one with no parent when looking at the root."""
604+
ambiguous_xml = GET_BY_NAME_AMBIGUOUS_ROOT_XML.read_text()
605+
baseurl = server.projects.baseurl
606+
607+
def respond(request, context):
608+
params = _filter_params(request)
609+
if params.get("name") == "Shared" and "parentProjectId" not in params:
610+
return ambiguous_xml
611+
context.status_code = 404
612+
return ""
613+
614+
with requests_mock.mock() as m:
615+
m.get(baseurl, text=respond)
616+
project = server.projects.get_by_path("Shared")
617+
618+
# The implementation filters to parent_id is None at the root level, so
619+
# only "bbbbbbbb-..." (the actual top-level project) should be returned.
620+
assert project is not None
621+
assert project.id == "bbbbbbbb-0000-0000-0000-000000000002"
622+
assert project.parent_id is None
623+
624+
625+
def test_get_by_path_duplicate_siblings_returns_first(server: TSC.Server) -> None:
626+
"""When multiple sibling projects share the same name, the first one is returned."""
627+
top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
628+
duplicate_xml = GET_BY_NAME_DUPLICATE_SIBLINGS_XML.read_text()
629+
baseurl = server.projects.baseurl
630+
631+
def respond(request, context):
632+
params = _filter_params(request)
633+
if params.get("name") == "Tableau" and "parentProjectId" not in params:
634+
return top_level_xml
635+
if params.get("name") == "Reports" and params.get("parentProjectId") == "1d0304cd-3796-429f-b815-7258370b9b74":
636+
return duplicate_xml
637+
context.status_code = 404
638+
return ""
639+
640+
with requests_mock.mock() as m:
641+
m.get(baseurl, text=respond)
642+
project = server.projects.get_by_path("Tableau/Reports")
643+
644+
# Duplicate siblings: implementation takes the first result from the API.
645+
assert project is not None
646+
assert project.id == "cccccccc-0000-0000-0000-000000000001"
647+
648+
649+
def test_get_by_path_deep_three_levels(server: TSC.Server) -> None:
650+
"""A three-component path issues three requests and returns the deepest project."""
651+
top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
652+
child_xml = GET_BY_NAME_CHILD_XML.read_text()
653+
grandchild_xml = GET_BY_NAME_GRANDCHILD_XML.read_text()
654+
baseurl = server.projects.baseurl
655+
656+
def respond(request, context):
657+
params = _filter_params(request)
658+
if params.get("name") == "Tableau" and "parentProjectId" not in params:
659+
return top_level_xml
660+
if params.get("name") == "Child 1" and params.get("parentProjectId") == "1d0304cd-3796-429f-b815-7258370b9b74":
661+
return child_xml
662+
if params.get("name") == "Q1" and params.get("parentProjectId") == "4cc52973-5e3a-4d1f-a4fb-5b5f73796edf":
663+
return grandchild_xml
664+
context.status_code = 404
665+
return ""
666+
667+
with requests_mock.mock() as m:
668+
m.get(baseurl, text=respond)
669+
project = server.projects.get_by_path("Tableau/Child 1/Q1")
670+
671+
assert project is not None
672+
assert project.id == "eeeeeeee-0000-0000-0000-000000000001"
673+
assert project.name == "Q1"
674+
assert project.parent_id == "4cc52973-5e3a-4d1f-a4fb-5b5f73796edf"
675+
676+
677+
def test_get_by_path_with_spaces_in_name(server: TSC.Server) -> None:
678+
"""Project names containing spaces are handled correctly."""
679+
# Reuses the child fixture whose name is 'Child 1' (contains a space).
680+
top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text()
681+
child_xml = GET_BY_NAME_CHILD_XML.read_text()
682+
baseurl = server.projects.baseurl
683+
684+
def respond(request, context):
685+
params = _filter_params(request)
686+
if params.get("name") == "Tableau" and "parentProjectId" not in params:
687+
return top_level_xml
688+
if params.get("name") == "Child 1" and params.get("parentProjectId") == "1d0304cd-3796-429f-b815-7258370b9b74":
689+
return child_xml
690+
context.status_code = 404
691+
return ""
692+
693+
with requests_mock.mock() as m:
694+
m.get(baseurl, text=respond)
695+
project = server.projects.get_by_path("Tableau/Child 1")
696+
697+
assert project is not None
698+
assert project.id == "4cc52973-5e3a-4d1f-a4fb-5b5f73796edf"
699+
assert project.name == "Child 1"

0 commit comments

Comments
 (0)