diff --git a/.github/workflows/check-coverage.yml b/.github/workflows/check-coverage.yml index 3d4996e4..fe9b244d 100644 --- a/.github/workflows/check-coverage.yml +++ b/.github/workflows/check-coverage.yml @@ -18,10 +18,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 0cb6f814..06d92ba4 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/generate-metadata.yml b/.github/workflows/generate-metadata.yml index 53449eb9..7e59af3e 100644 --- a/.github/workflows/generate-metadata.yml +++ b/.github/workflows/generate-metadata.yml @@ -11,10 +11,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.12' diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index d9b65a93..fe851902 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -60,9 +60,9 @@ jobs: UPLOAD_FILE_NAME: tabcmd steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: 3.12 diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index bc101762..96400505 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -15,10 +15,10 @@ jobs: name: Build dist files for PyPi runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: 3.12 - name: Build dist files diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 023a4a7d..c294aff4 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: pip install Tabcmd diff --git a/.github/workflows/run-e2-tests.yml b/.github/workflows/run-e2-tests.yml index 81ebb4f6..0f04cdab 100644 --- a/.github/workflows/run-e2-tests.yml +++ b/.github/workflows/run-e2-tests.yml @@ -23,10 +23,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 4e9d279a..fca98373 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -20,10 +20,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000..00cd7bd1 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +#ECCN:Open Source diff --git a/tabcmd/commands/server.py b/tabcmd/commands/server.py index 19136141..eb466183 100644 --- a/tabcmd/commands/server.py +++ b/tabcmd/commands/server.py @@ -52,7 +52,6 @@ def get_items_by_name(logger, item_endpoint, item_name: str, container: Optional logger.debug(_("export.status").format(item_log_name)) result = [] - total_available_items = None page_number = 1 total_retrieved_items = 0 @@ -62,9 +61,9 @@ def get_items_by_name(logger, item_endpoint, item_name: str, container: Optional TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, item_name) ) - # todo - this doesn't filter if the project is in the top level. - # todo: there is no guarantee that these fields are the same for different content types. - # probably better if we move that type specific logic out to a wrapper + # When a container (project) is provided, add a parent filter to narrow results coming back + # from the server, then additionally post-filter client-side by project_id to disambiguate + # cases where multiple projects share the same name under different parent paths. if container: # the name of the filter field is different if you are finding a project or any other item if type(item_endpoint).__name__.find("Projects") < 0: @@ -87,6 +86,10 @@ def get_items_by_name(logger, item_endpoint, item_name: str, container: Optional ) total_retrieved_items += len(all_items) + # Post-filter by exact project_id when looking up non-project items within a specific container. + # This prevents selecting a similarly named item from a sibling project that shares the same name. + if container and type(item_endpoint).__name__.find("Projects") < 0: + all_items = [i for i in all_items if getattr(i, "project_id", None) == container.id] logger.debug( "{} items of name: {} were found for query page number: {}, page size: {} & total available: {}".format( @@ -99,11 +102,27 @@ def get_items_by_name(logger, item_endpoint, item_name: str, container: Optional ) result.extend(all_items) + + # Once we have disambiguated items for a specific container/project, there is no need to + # continue paginating further since additional pages would return the same name-matched + # items from other projects which are filtered out, leading to duplicates. + if container and type(item_endpoint).__name__.find("Projects") < 0 and len(all_items) > 0: + break + if total_retrieved_items >= pagination_item.total_available: break page_number = pagination_item.page_number + 1 + # If a container was provided and no items remained after disambiguation by project_id, + # surface a not-found to align with server-side not-found semantics. + if container and type(item_endpoint).__name__.find("Projects") < 0 and len(result) == 0: + raise TSC.ServerResponseError( + code="404", + summary=_("errors.xmlapi.not_found"), + detail=_("errors.xmlapi.not_found") + ": " + item_log_name, + ) + return result # Get site by name or get currently logged in site diff --git a/tests/commands/test_server_item_selection.py b/tests/commands/test_server_item_selection.py new file mode 100644 index 00000000..c9d76df8 --- /dev/null +++ b/tests/commands/test_server_item_selection.py @@ -0,0 +1,94 @@ +import pytest +import tableauserverclient as TSC + +from tabcmd.commands.server import Server + + +class _Logger: + def debug(self, *_args, **_kwargs): + pass + + +class _DummyItem: + def __init__(self, name: str, project_id: str): + self.name = name + self.project_id = project_id + + +class _DummyPagination: + def __init__(self, total_available: int, page_number: int = 1, page_size: int = 100): + self.total_available = total_available + self.page_number = page_number + self.page_size = page_size + + +class _DatasourcesEndpoint: + def __init__(self, items): + self._items = items + + def get(self, _req_option: TSC.RequestOptions): + # Ignore server-side filters for this unit test; we validate client-side disambiguation + return self._items, _DummyPagination(total_available=len(self._items)) + + +class _ProjectItem: + # Minimal project-like object carrying id/name used by Server.get_items_by_name + def __init__(self, project_id: str, name: str): + self.id = project_id + self.name = name + + +def test_filters_datasources_by_exact_project_id_when_container_provided(): + logger = _Logger() + container = _ProjectItem(project_id="proj-A", name="Shared") + # Two datasources with identical names, different project ownership + items = [ + _DummyItem(name="Sales", project_id="proj-A"), + _DummyItem(name="Sales", project_id="proj-B"), + ] + endpoint = _DatasourcesEndpoint(items) + + results = Server.get_items_by_name(logger, endpoint, "Sales", container) + + assert len(results) == 1 + assert results[0].project_id == "proj-A" + + +def test_raises_not_found_when_no_items_match_container_after_disambiguation(): + logger = _Logger() + container = _ProjectItem(project_id="proj-Z", name="Shared") + items = [ + _DummyItem(name="Sales", project_id="proj-A"), + _DummyItem(name="Sales", project_id="proj-B"), + ] + endpoint = _DatasourcesEndpoint(items) + + with pytest.raises(TSC.ServerResponseError): + Server.get_items_by_name(logger, endpoint, "Sales", container) + + +def test_nested_projects_same_leaf_name_returns_correct_datasource_per_container(): + logger = _Logger() + # Simulate three separate 'Cats' projects that exist at different levels: + # MyProjects/ProjectA/Cats, MyProjects/ProjectB/Cats, and MyProjects/Cats + cats_under_project_a = _ProjectItem(project_id="cats-A", name="Cats") + cats_under_project_b = _ProjectItem(project_id="cats-B", name="Cats") + cats_under_root = _ProjectItem(project_id="cats-root", name="Cats") + + # Three datasources all named identically but owned by different 'Cats' projects + items = [ + _DummyItem(name="my-datasource", project_id="cats-A"), + _DummyItem(name="my-datasource", project_id="cats-B"), + _DummyItem(name="my-datasource", project_id="cats-root"), + ] + endpoint = _DatasourcesEndpoint(items) + + # Each lookup should return exactly one item from the target project id + res_a = Server.get_items_by_name(logger, endpoint, "my-datasource", cats_under_project_a) + assert len(res_a) == 1 and res_a[0].project_id == "cats-A" + + res_b = Server.get_items_by_name(logger, endpoint, "my-datasource", cats_under_project_b) + assert len(res_b) == 1 and res_b[0].project_id == "cats-B" + + res_root = Server.get_items_by_name(logger, endpoint, "my-datasource", cats_under_root) + assert len(res_root) == 1 and res_root[0].project_id == "cats-root"