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"