Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/check-coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/codeql-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/generate-metadata.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/run-e2-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
#ECCN:Open Source
27 changes: 23 additions & 4 deletions tabcmd/commands/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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(
Expand All @@ -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:
Comment thread
jacalata marked this conversation as resolved.
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
Expand Down
94 changes: 94 additions & 0 deletions tests/commands/test_server_item_selection.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
jacalata marked this conversation as resolved.

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))


Comment thread
jacalata marked this conversation as resolved.
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"