From f87287e6acc5023707b026ea1eabcedf011d1953 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 25 Jun 2026 17:31:50 -0700 Subject: [PATCH 1/8] feat: add BaseItem protocol for item type safety --- tableauserverclient/models/__init__.py | 3 ++ tableauserverclient/models/base_item.py | 66 +++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 tableauserverclient/models/base_item.py diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index aa28e0dbf..b0bc3061d 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -1,3 +1,4 @@ +from tableauserverclient.models.base_item import BaseItem, ContentItem from tableauserverclient.models.collection_item import CollectionItem from tableauserverclient.models.column_item import ColumnItem from tableauserverclient.models.connection_credentials import ConnectionCredentials @@ -55,6 +56,8 @@ from tableauserverclient.models.extract_item import ExtractItem __all__ = [ + "BaseItem", + "ContentItem", "CollectionItem", "ColumnItem", "ConnectionCredentials", diff --git a/tableauserverclient/models/base_item.py b/tableauserverclient/models/base_item.py new file mode 100644 index 000000000..ab6f0ea3c --- /dev/null +++ b/tableauserverclient/models/base_item.py @@ -0,0 +1,66 @@ +"""Structural protocols for TSC item classes. + +These protocols define the minimum interface shared across TSC resource items. +They use ``typing.Protocol`` (structural subtyping) rather than an ABC so that +existing classes do not need to modify their inheritance chain to satisfy the +contract. Any class that exposes the required attributes satisfies the +protocol automatically -- no explicit inheritance is required or desired. +""" + +from __future__ import annotations + +import datetime +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class BaseItem(Protocol): + """Structural interface satisfied by all primary TSC resource item classes. + + Every TSC item class (WorkbookItem, DatasourceItem, ViewItem, FlowItem, + UserItem, ProjectItem, ScheduleItem, GroupItem) exposes at minimum an ``id`` + attribute and a ``name`` attribute. This protocol captures that minimal + shared surface. + + ``id`` and ``name`` are declared as plain Protocol attributes (not + ``@property``) so that concrete classes may implement them as either plain + instance attributes or read-only properties. Protocol structural subtyping + means no concrete class needs to list ``BaseItem`` in its MRO -- any class + with matching attributes satisfies the protocol implicitly. + + Notes + ----- + ``runtime_checkable`` enables ``isinstance(obj, BaseItem)`` checks at + runtime, but these only verify attribute *presence*, not types or + signatures. Full static checking requires a type checker such as mypy. + + ``from_response`` is intentionally excluded from this protocol because the + four primary content classes have divergent signatures (different ``resp`` + parameter types, extra parameters) that cannot be unified without widening + to ``Any``. + """ + + id: str | None + name: str | None + + +@runtime_checkable +class ContentItem(BaseItem, Protocol): + """Extended interface for publishable content items. + + Structurally satisfied by WorkbookItem, DatasourceItem, ViewItem, and + FlowItem -- the four classes that carry timestamps and a mutable tag set. + ProjectItem and UserItem are intentionally excluded because they lack + ``tags``, ``created_at``, or ``updated_at``. + + No concrete class needs to explicitly inherit from ContentItem. Protocol + structural subtyping means any class that exposes all required attributes + satisfies the protocol implicitly, avoiding mypy [override] errors that + arise when a Protocol with plain writable annotations is explicitly + subclassed by a class that implements them as read-only properties. + """ + + created_at: datetime.datetime | None + updated_at: datetime.datetime | None + # Plain mutable attribute on all four classes. + tags: set[str] From c3a8043e2f4c930a9a59a2c0d12a9d8fe222785b Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 25 Jun 2026 18:35:21 -0700 Subject: [PATCH 2/8] feat: add OwnedItem and TaggableItem protocols to base_item.py --- tableauserverclient/models/__init__.py | 4 ++- tableauserverclient/models/base_item.py | 47 +++++++++++++++++++++---- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index b0bc3061d..4d9443b4c 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -1,4 +1,4 @@ -from tableauserverclient.models.base_item import BaseItem, ContentItem +from tableauserverclient.models.base_item import BaseItem, ContentItem, OwnedItem, TaggableItem from tableauserverclient.models.collection_item import CollectionItem from tableauserverclient.models.column_item import ColumnItem from tableauserverclient.models.connection_credentials import ConnectionCredentials @@ -58,6 +58,8 @@ __all__ = [ "BaseItem", "ContentItem", + "OwnedItem", + "TaggableItem", "CollectionItem", "ColumnItem", "ConnectionCredentials", diff --git a/tableauserverclient/models/base_item.py b/tableauserverclient/models/base_item.py index ab6f0ea3c..0368264dc 100644 --- a/tableauserverclient/models/base_item.py +++ b/tableauserverclient/models/base_item.py @@ -45,13 +45,48 @@ class BaseItem(Protocol): @runtime_checkable -class ContentItem(BaseItem, Protocol): +class OwnedItem(BaseItem, Protocol): + """Structural interface for TSC items that carry an owner reference. + + Structurally satisfied by WorkbookItem, DatasourceItem, ViewItem, + FlowItem, ProjectItem, and MetricItem -- every item class that exposes + an ``owner_id`` attribute. Note that ProjectItem and MetricItem satisfy + this protocol even though they do not satisfy ContentItem: they have an + owner but lack timestamps and tags. + + No concrete class needs to explicitly inherit from OwnedItem. Protocol + structural subtyping means any class that exposes the required attribute + satisfies the protocol implicitly. + """ + + owner_id: str | None + + +@runtime_checkable +class TaggableItem(BaseItem, Protocol): + """Structural interface for TSC items that carry a mutable tag set. + + Structurally satisfied by WorkbookItem, DatasourceItem, ViewItem, + FlowItem, and MetricItem. ProjectItem is intentionally excluded because + it does not expose a ``tags`` attribute. + + This is the interface duck-typed by ``_ResourceTagger.update_tags`` in the + server layer. Formalising it as a protocol enables type-safe tagging + helpers without requiring concrete classes to change their inheritance + chain. + """ + + tags: set[str] + _initial_tags: set[str] + + +@runtime_checkable +class ContentItem(OwnedItem, TaggableItem, Protocol): """Extended interface for publishable content items. - Structurally satisfied by WorkbookItem, DatasourceItem, ViewItem, and - FlowItem -- the four classes that carry timestamps and a mutable tag set. - ProjectItem and UserItem are intentionally excluded because they lack - ``tags``, ``created_at``, or ``updated_at``. + Composes OwnedItem (carries ``owner_id``), TaggableItem (carries ``tags`` + and ``_initial_tags``), and adds server-assigned timestamps. Structurally + satisfied by WorkbookItem, DatasourceItem, ViewItem, and FlowItem. No concrete class needs to explicitly inherit from ContentItem. Protocol structural subtyping means any class that exposes all required attributes @@ -62,5 +97,3 @@ class ContentItem(BaseItem, Protocol): created_at: datetime.datetime | None updated_at: datetime.datetime | None - # Plain mutable attribute on all four classes. - tags: set[str] From 14fa8342ea15afbdd1d08bbdac2c495a2d42525b Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 25 Jun 2026 18:54:42 -0700 Subject: [PATCH 3/8] Fix review findings: name collision, MetricItem coverage, _initial_tags type - Rename DefaultPermissionsEndpoint's local BaseItem alias to DefaultPermissionsTarget to avoid shadowing the new public Protocol - Remove _initial_tags from TaggableItem (internal dirty-tracking detail, not a public contract); update ContentItem docstring to include MetricItem - Narrow WorkbookItem._initial_tags and DatasourceItem._initial_tags annotations from bare set to set[str] for Protocol invariance compliance Co-Authored-By: Claude Sonnet 4.6 --- tableauserverclient/models/base_item.py | 16 ++++------------ tableauserverclient/models/datasource_item.py | 2 +- tableauserverclient/models/workbook_item.py | 2 +- .../endpoint/default_permissions_endpoint.py | 10 +++++----- 4 files changed, 11 insertions(+), 19 deletions(-) diff --git a/tableauserverclient/models/base_item.py b/tableauserverclient/models/base_item.py index 0368264dc..a3249f0fe 100644 --- a/tableauserverclient/models/base_item.py +++ b/tableauserverclient/models/base_item.py @@ -50,9 +50,7 @@ class OwnedItem(BaseItem, Protocol): Structurally satisfied by WorkbookItem, DatasourceItem, ViewItem, FlowItem, ProjectItem, and MetricItem -- every item class that exposes - an ``owner_id`` attribute. Note that ProjectItem and MetricItem satisfy - this protocol even though they do not satisfy ContentItem: they have an - owner but lack timestamps and tags. + an ``owner_id`` attribute. No concrete class needs to explicitly inherit from OwnedItem. Protocol structural subtyping means any class that exposes the required attribute @@ -69,24 +67,18 @@ class TaggableItem(BaseItem, Protocol): Structurally satisfied by WorkbookItem, DatasourceItem, ViewItem, FlowItem, and MetricItem. ProjectItem is intentionally excluded because it does not expose a ``tags`` attribute. - - This is the interface duck-typed by ``_ResourceTagger.update_tags`` in the - server layer. Formalising it as a protocol enables type-safe tagging - helpers without requiring concrete classes to change their inheritance - chain. """ tags: set[str] - _initial_tags: set[str] @runtime_checkable class ContentItem(OwnedItem, TaggableItem, Protocol): """Extended interface for publishable content items. - Composes OwnedItem (carries ``owner_id``), TaggableItem (carries ``tags`` - and ``_initial_tags``), and adds server-assigned timestamps. Structurally - satisfied by WorkbookItem, DatasourceItem, ViewItem, and FlowItem. + Composes OwnedItem (carries ``owner_id``), TaggableItem (carries ``tags``), + and adds server-assigned timestamps. Structurally satisfied by + WorkbookItem, DatasourceItem, ViewItem, FlowItem, and MetricItem. No concrete class needs to explicitly inherit from ContentItem. Protocol structural subtyping means any class that exposes all required attributes diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 6ed1fbdd4..b12334521 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -154,7 +154,7 @@ def __init__(self, project_id: str | None = None, name: str | None = None) -> No self._encrypt_extracts: bool | None = None self._has_extracts: bool | None = None self._id: str | None = None - self._initial_tags: set = set() + self._initial_tags: set[str] = set() self._project_name: str | None = None self._revisions = None self._size: int | None = None diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 352923389..7b62906e8 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -136,7 +136,7 @@ def __init__( self._webpage_url = None self._created_at = None self._id: str | None = None - self._initial_tags: set = set() + self._initial_tags: set[str] = set() self._pdf = None self._powerpoint = None self._preview_image = None diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index d788ac9f0..be632555d 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -14,7 +14,7 @@ from tableauserverclient.helpers.logging import logger # these are the only two items that can hold default permissions for another type -BaseItem = DatabaseItem | ProjectItem +DefaultPermissionsTarget = DatabaseItem | ProjectItem class _DefaultPermissionsEndpoint(Endpoint): @@ -39,7 +39,7 @@ def __str__(self): __repr__ = __str__ def update_default_permissions( - self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Resource | str + self, resource: DefaultPermissionsTarget, permissions: Sequence[PermissionsRule], content_type: Resource | str ) -> list[PermissionsRule]: url = f"{self.owner_baseurl()}/{resource.id}/default-permissions/{plural_type(content_type)}" update_req = RequestFactory.Permission.add_req(permissions) @@ -51,7 +51,7 @@ def update_default_permissions( return permissions def delete_default_permission( - self, resource: BaseItem, rule: PermissionsRule, content_type: Resource | str + self, resource: DefaultPermissionsTarget, rule: PermissionsRule, content_type: Resource | str ) -> None: for capability, mode in rule.capabilities.items(): # Made readability better but line is too long, will make this look better @@ -74,7 +74,7 @@ def delete_default_permission( logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}") - def populate_default_permissions(self, item: BaseItem, content_type: Resource | str) -> None: + def populate_default_permissions(self, item: DefaultPermissionsTarget, content_type: Resource | str) -> None: if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -86,7 +86,7 @@ def permission_fetcher() -> list[PermissionsRule]: logger.info(f"Populated default {content_type} permissions for item (ID: {item.id})") def _get_default_permissions( - self, item: BaseItem, content_type: Resource | str, req_options: "RequestOptions | None" = None + self, item: DefaultPermissionsTarget, content_type: Resource | str, req_options: "RequestOptions | None" = None ) -> list[PermissionsRule]: url = f"{self.owner_baseurl()}/{item.id}/default-permissions/{plural_type(content_type)}" server_response = self.get_request(url, req_options) From 3f3339b147692df083785193910f95ed5633d35d Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 25 Jun 2026 19:03:59 -0700 Subject: [PATCH 4/8] Remove dead Taggable protocol; fix duplicate SiteOIDCConfiguration in __all__ Taggable in resource_tagger.py was never used as a type bound -- TaggableItem in base_item.py now covers the public contract. Also remove runtime_checkable import which became unused. Fix pre-existing duplicate SiteOIDCConfiguration entry in models/__init__.__all__. Co-Authored-By: Claude Sonnet 4.6 --- tableauserverclient/models/__init__.py | 1 - .../server/endpoint/resource_tagger.py | 12 +----------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 4d9443b4c..93bab2db8 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -98,7 +98,6 @@ "ServerInfoItem", "SiteAuthConfiguration", "SiteItem", - "SiteOIDCConfiguration", "SubscriptionItem", "TableItem", "TableauAuth", diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 72c7274c8..c9eeee13b 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,6 +1,6 @@ import abc import copy -from typing import Generic, Protocol, TypeVar, TYPE_CHECKING, runtime_checkable +from typing import Generic, Protocol, TypeVar, TYPE_CHECKING from collections.abc import Iterable import urllib.parse @@ -67,16 +67,6 @@ class Response(Protocol): content: bytes -@runtime_checkable -class Taggable(Protocol): - tags: set[str] - _initial_tags: set[str] - - @property - def id(self) -> str | None: - pass - - T = TypeVar("T") From 5389b6c5f0411e28f5950d3f889be389724bef32 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 25 Jun 2026 21:49:36 -0700 Subject: [PATCH 5/8] Declare OwnedItem.owner_id as read-only @property ViewItem.owner_id is not independently writable (it tracks the parent workbook's owner), so a plain writable Protocol attribute annotation would mislead mypy. A @property annotation satisfies both ViewItem's read-only property and the writable instance attributes on other item classes. Co-Authored-By: Claude Sonnet 4.6 --- tableauserverclient/models/base_item.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/models/base_item.py b/tableauserverclient/models/base_item.py index a3249f0fe..4c8a9f203 100644 --- a/tableauserverclient/models/base_item.py +++ b/tableauserverclient/models/base_item.py @@ -55,9 +55,15 @@ class OwnedItem(BaseItem, Protocol): No concrete class needs to explicitly inherit from OwnedItem. Protocol structural subtyping means any class that exposes the required attribute satisfies the protocol implicitly. + + ``owner_id`` is declared as a read-only ``@property`` so that ViewItem + (whose owner is determined by its parent workbook and is not independently + writable) satisfies the protocol. Plain writable instance attributes on + other item classes also satisfy a read-only property protocol. """ - owner_id: str | None + @property + def owner_id(self) -> str | None: ... @runtime_checkable From 84a0e29f0bace2de55d26f90ce112eccf14690a9 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 26 Jun 2026 02:19:41 -0700 Subject: [PATCH 6/8] Add protocol tests and annotate _ResourceTagger methods - Add test/test_protocols.py covering isinstance() checks for BaseItem, OwnedItem, TaggableItem, and ContentItem against all representative item classes (WorkbookItem, DatasourceItem, ViewItem, FlowItem, ProjectItem, MetricItem, UserItem) and plain structural objects, including negative cases and protocol-hierarchy checks. - Add _TaggableWithInitial private Protocol in resource_tagger.py to capture the _initial_tags implementation detail alongside the public TaggableItem surface; use it to fully annotate _ResourceTagger._add_tags, _delete_tag, and update_tags. Co-Authored-By: Claude Sonnet 4.6 --- .../server/endpoint/resource_tagger.py | 18 +- test/test_protocols.py | 261 ++++++++++++++++++ 2 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 test/test_protocols.py diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index c9eeee13b..e9f778a7e 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -22,9 +22,21 @@ from tableauserverclient.server.server import Server +class _TaggableWithInitial(Protocol): + """Private structural protocol for items managed by _ResourceTagger. + + Extends the public TaggableItem interface with the ``_initial_tags`` + implementation detail used internally to track server-side tag state. + """ + + id: str | None + tags: set[str] + _initial_tags: set[str] + + class _ResourceTagger(Endpoint): # Add new tags to resource - def _add_tags(self, baseurl, resource_id, tag_set): + def _add_tags(self, baseurl: str, resource_id: str | None, tag_set: set[str]) -> set[str]: url = f"{baseurl}/{resource_id}/tags" add_req = RequestFactory.Tag.add_req(tag_set) @@ -38,7 +50,7 @@ def _add_tags(self, baseurl, resource_id, tag_set): raise # Some other error # Delete a resource's tag by name - def _delete_tag(self, baseurl, resource_id, tag_name): + def _delete_tag(self, baseurl: str, resource_id: str | None, tag_name: str) -> None: encoded_tag_name = urllib.parse.quote(tag_name, safe="") url = f"{baseurl}/{resource_id}/tags/{encoded_tag_name}" @@ -51,7 +63,7 @@ def _delete_tag(self, baseurl, resource_id, tag_name): raise # Some other error # Remove and add tags to match the resource item's tag set - def update_tags(self, baseurl, resource_item): + def update_tags(self, baseurl: str, resource_item: _TaggableWithInitial) -> None: if resource_item.tags != resource_item._initial_tags: add_set = resource_item.tags - resource_item._initial_tags remove_set = resource_item._initial_tags - resource_item.tags diff --git a/test/test_protocols.py b/test/test_protocols.py new file mode 100644 index 000000000..4dcdeecef --- /dev/null +++ b/test/test_protocols.py @@ -0,0 +1,261 @@ +"""Tests for the structural protocols defined in tableauserverclient.models.base_item. + +Verifies that: +- Each runtime_checkable protocol can be used with isinstance(). +- Representative item classes satisfy the protocols they are documented to satisfy. +- Items that lack required attributes do NOT satisfy stricter protocols. +""" + +import tableauserverclient as TSC +from tableauserverclient.models.base_item import BaseItem, ContentItem, OwnedItem, TaggableItem + +# --------------------------------------------------------------------------- +# BaseItem: id + name +# --------------------------------------------------------------------------- + + +class TestBaseItem: + def test_workbook_satisfies_base_item(self): + item = TSC.WorkbookItem(project_id="p1", name="wb") + assert isinstance(item, BaseItem) + + def test_datasource_satisfies_base_item(self): + item = TSC.DatasourceItem(project_id="p1", name="ds") + assert isinstance(item, BaseItem) + + def test_view_satisfies_base_item(self): + item = TSC.ViewItem() + assert isinstance(item, BaseItem) + + def test_flow_satisfies_base_item(self): + item = TSC.FlowItem(project_id="p1", name="f") + assert isinstance(item, BaseItem) + + def test_project_satisfies_base_item(self): + item = TSC.ProjectItem(name="proj") + assert isinstance(item, BaseItem) + + def test_metric_satisfies_base_item(self): + item = TSC.MetricItem() + assert isinstance(item, BaseItem) + + def test_user_satisfies_base_item(self): + item = TSC.UserItem(name="u", site_role="Viewer") + assert isinstance(item, BaseItem) + + def test_plain_object_with_id_and_name_satisfies_base_item(self): + """Structural subtyping: any object with id and name suffices.""" + + class Minimal: + id: str | None = None + name: str | None = "x" + + assert isinstance(Minimal(), BaseItem) + + def test_object_missing_name_does_not_satisfy_base_item(self): + class NoName: + id: str | None = None + + assert not isinstance(NoName(), BaseItem) + + def test_object_missing_id_does_not_satisfy_base_item(self): + class NoId: + name: str | None = "x" + + assert not isinstance(NoId(), BaseItem) + + +# --------------------------------------------------------------------------- +# OwnedItem: BaseItem + owner_id +# --------------------------------------------------------------------------- + + +class TestOwnedItem: + def test_workbook_satisfies_owned_item(self): + item = TSC.WorkbookItem(project_id="p1", name="wb") + assert isinstance(item, OwnedItem) + + def test_datasource_satisfies_owned_item(self): + item = TSC.DatasourceItem(project_id="p1", name="ds") + assert isinstance(item, OwnedItem) + + def test_view_satisfies_owned_item(self): + item = TSC.ViewItem() + assert isinstance(item, OwnedItem) + + def test_flow_satisfies_owned_item(self): + item = TSC.FlowItem(project_id="p1", name="f") + assert isinstance(item, OwnedItem) + + def test_project_satisfies_owned_item(self): + item = TSC.ProjectItem(name="proj") + assert isinstance(item, OwnedItem) + + def test_metric_satisfies_owned_item(self): + item = TSC.MetricItem() + assert isinstance(item, OwnedItem) + + def test_user_does_not_satisfy_owned_item(self): + """UserItem has no owner_id attribute.""" + item = TSC.UserItem(name="u", site_role="Viewer") + assert not isinstance(item, OwnedItem) + + def test_plain_object_satisfies_owned_item(self): + class Owned: + id: str | None = None + name: str | None = "x" + + @property + def owner_id(self) -> str | None: + return None + + assert isinstance(Owned(), OwnedItem) + + def test_object_missing_owner_id_does_not_satisfy_owned_item(self): + class NoOwner: + id: str | None = None + name: str | None = "x" + + assert not isinstance(NoOwner(), OwnedItem) + + +# --------------------------------------------------------------------------- +# TaggableItem: BaseItem + tags +# --------------------------------------------------------------------------- + + +class TestTaggableItem: + def test_workbook_satisfies_taggable_item(self): + item = TSC.WorkbookItem(project_id="p1", name="wb") + assert isinstance(item, TaggableItem) + + def test_datasource_satisfies_taggable_item(self): + item = TSC.DatasourceItem(project_id="p1", name="ds") + assert isinstance(item, TaggableItem) + + def test_view_satisfies_taggable_item(self): + item = TSC.ViewItem() + assert isinstance(item, TaggableItem) + + def test_flow_satisfies_taggable_item(self): + item = TSC.FlowItem(project_id="p1", name="f") + assert isinstance(item, TaggableItem) + + def test_metric_satisfies_taggable_item(self): + item = TSC.MetricItem() + assert isinstance(item, TaggableItem) + + def test_project_does_not_satisfy_taggable_item(self): + """ProjectItem does not expose a tags attribute.""" + item = TSC.ProjectItem(name="proj") + assert not isinstance(item, TaggableItem) + + def test_user_does_not_satisfy_taggable_item(self): + """UserItem does not expose a tags attribute.""" + item = TSC.UserItem(name="u", site_role="Viewer") + assert not isinstance(item, TaggableItem) + + def test_plain_object_with_tags_satisfies_taggable_item(self): + class Tagged: + id: str | None = None + name: str | None = "x" + tags: set = set() + + assert isinstance(Tagged(), TaggableItem) + + def test_object_missing_tags_does_not_satisfy_taggable_item(self): + class NoTags: + id: str | None = None + name: str | None = "x" + + assert not isinstance(NoTags(), TaggableItem) + + +# --------------------------------------------------------------------------- +# ContentItem: OwnedItem + TaggableItem + created_at + updated_at +# --------------------------------------------------------------------------- + + +class TestContentItem: + def test_workbook_satisfies_content_item(self): + item = TSC.WorkbookItem(project_id="p1", name="wb") + assert isinstance(item, ContentItem) + + def test_datasource_satisfies_content_item(self): + item = TSC.DatasourceItem(project_id="p1", name="ds") + assert isinstance(item, ContentItem) + + def test_view_satisfies_content_item(self): + item = TSC.ViewItem() + assert isinstance(item, ContentItem) + + def test_flow_satisfies_content_item(self): + item = TSC.FlowItem(project_id="p1", name="f") + assert isinstance(item, ContentItem) + + def test_metric_satisfies_content_item(self): + item = TSC.MetricItem() + assert isinstance(item, ContentItem) + + def test_project_does_not_satisfy_content_item(self): + """ProjectItem lacks tags, so it cannot satisfy ContentItem.""" + item = TSC.ProjectItem(name="proj") + assert not isinstance(item, ContentItem) + + def test_user_does_not_satisfy_content_item(self): + """UserItem lacks owner_id and tags.""" + item = TSC.UserItem(name="u", site_role="Viewer") + assert not isinstance(item, ContentItem) + + def test_plain_object_missing_timestamps_does_not_satisfy_content_item(self): + class NoTimestamps: + id: str | None = None + name: str | None = "x" + tags: set = set() + + @property + def owner_id(self) -> str | None: + return None + + assert not isinstance(NoTimestamps(), ContentItem) + + def test_complete_plain_object_satisfies_content_item(self): + import datetime + + class Full: + id: str | None = None + name: str | None = "x" + tags: set = set() + created_at: datetime.datetime | None = None + updated_at: datetime.datetime | None = None + + @property + def owner_id(self) -> str | None: + return None + + assert isinstance(Full(), ContentItem) + + +# --------------------------------------------------------------------------- +# Protocol hierarchy: ContentItem implies OwnedItem, TaggableItem, and BaseItem +# --------------------------------------------------------------------------- + + +class TestProtocolHierarchy: + """An item that satisfies ContentItem must also satisfy every parent protocol.""" + + def test_content_item_satisfier_also_satisfies_owned_item(self): + item = TSC.WorkbookItem(project_id="p1", name="wb") + assert isinstance(item, OwnedItem) + + def test_content_item_satisfier_also_satisfies_taggable_item(self): + item = TSC.WorkbookItem(project_id="p1", name="wb") + assert isinstance(item, TaggableItem) + + def test_content_item_satisfier_also_satisfies_base_item(self): + item = TSC.WorkbookItem(project_id="p1", name="wb") + assert isinstance(item, BaseItem) + + def test_owned_item_satisfier_also_satisfies_base_item(self): + item = TSC.ProjectItem(name="proj") + assert isinstance(item, BaseItem) From af7d95571f196cc00cb2c0bff61d97be3c67b55d Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 26 Jun 2026 14:13:43 -0700 Subject: [PATCH 7/8] style: add grouping comments to __all__ and hoist datetime import in test Add section comments to __init__.__all__ to distinguish the new structural protocols from the alphabetical concrete-model list. Move the inline `import datetime` in test_protocols.py to module level, matching the import style used throughout the test suite. Co-Authored-By: Claude Sonnet 4.6 --- tableauserverclient/models/__init__.py | 2 ++ test/test_protocols.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 93bab2db8..574436998 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -56,10 +56,12 @@ from tableauserverclient.models.extract_item import ExtractItem __all__ = [ + # Structural protocols (base_item.py) "BaseItem", "ContentItem", "OwnedItem", "TaggableItem", + # Concrete model classes (alphabetical) "CollectionItem", "ColumnItem", "ConnectionCredentials", diff --git a/test/test_protocols.py b/test/test_protocols.py index 4dcdeecef..10638c1a2 100644 --- a/test/test_protocols.py +++ b/test/test_protocols.py @@ -6,6 +6,8 @@ - Items that lack required attributes do NOT satisfy stricter protocols. """ +import datetime + import tableauserverclient as TSC from tableauserverclient.models.base_item import BaseItem, ContentItem, OwnedItem, TaggableItem @@ -220,8 +222,6 @@ def owner_id(self) -> str | None: assert not isinstance(NoTimestamps(), ContentItem) def test_complete_plain_object_satisfies_content_item(self): - import datetime - class Full: id: str | None = None name: str | None = "x" From 506ae8c195b33866a0e25d26d721db157253bfe4 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Sat, 27 Jun 2026 00:27:26 -0700 Subject: [PATCH 8/8] rename BaseItem -> TableauItem Protocol, replace Union in tableau_types.py BaseItem is renamed to TableauItem to match the existing name in tableau_types.py and fulfill its TODO comment ("should define TableauItem as an interface"). The Union type alias is replaced with an import of the Protocol, removing the need to enumerate concrete types. id and name are now declared as @property on the Protocol so that concrete classes with read-only property implementations (and VirtualConnectionItem whose name: str is narrower than str | None) satisfy it under mypy's covariant property checking. A private _PermissibleItem Protocol is added to permissions_endpoint.py to type the populate() method's _set_permissions call, which is an implementation detail not appropriate for the public TableauItem Protocol. Co-Authored-By: Claude Sonnet 4.6 --- tableauserverclient/models/__init__.py | 7 +-- tableauserverclient/models/base_item.py | 27 ++++++--- tableauserverclient/models/tableau_types.py | 27 +-------- .../server/endpoint/permissions_endpoint.py | 13 ++++- test/test_protocols.py | 56 +++++++++---------- 5 files changed, 63 insertions(+), 67 deletions(-) diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 574436998..0e292338c 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -1,4 +1,4 @@ -from tableauserverclient.models.base_item import BaseItem, ContentItem, OwnedItem, TaggableItem +from tableauserverclient.models.base_item import TableauItem, ContentItem, OwnedItem, TaggableItem from tableauserverclient.models.collection_item import CollectionItem from tableauserverclient.models.column_item import ColumnItem from tableauserverclient.models.connection_credentials import ConnectionCredentials @@ -44,7 +44,7 @@ from tableauserverclient.models.subscription_item import SubscriptionItem from tableauserverclient.models.table_item import TableItem from tableauserverclient.models.tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth, JWTAuth -from tableauserverclient.models.tableau_types import Resource, TableauItem, plural_type +from tableauserverclient.models.tableau_types import Resource, plural_type from tableauserverclient.models.tag_item import TagItem from tableauserverclient.models.target import Target from tableauserverclient.models.task_item import TaskItem @@ -57,7 +57,7 @@ __all__ = [ # Structural protocols (base_item.py) - "BaseItem", + "TableauItem", "ContentItem", "OwnedItem", "TaggableItem", @@ -106,7 +106,6 @@ "PersonalAccessTokenAuth", "JWTAuth", "Resource", - "TableauItem", "plural_type", "TagItem", "Target", diff --git a/tableauserverclient/models/base_item.py b/tableauserverclient/models/base_item.py index 4c8a9f203..d07644ead 100644 --- a/tableauserverclient/models/base_item.py +++ b/tableauserverclient/models/base_item.py @@ -14,23 +14,24 @@ @runtime_checkable -class BaseItem(Protocol): +class TableauItem(Protocol): """Structural interface satisfied by all primary TSC resource item classes. Every TSC item class (WorkbookItem, DatasourceItem, ViewItem, FlowItem, UserItem, ProjectItem, ScheduleItem, GroupItem) exposes at minimum an ``id`` attribute and a ``name`` attribute. This protocol captures that minimal - shared surface. + shared surface, fulfilling the role previously held by the ``TableauItem`` + Union type in ``tableau_types.py``. ``id`` and ``name`` are declared as plain Protocol attributes (not ``@property``) so that concrete classes may implement them as either plain instance attributes or read-only properties. Protocol structural subtyping - means no concrete class needs to list ``BaseItem`` in its MRO -- any class + means no concrete class needs to list ``TableauItem`` in its MRO -- any class with matching attributes satisfies the protocol implicitly. Notes ----- - ``runtime_checkable`` enables ``isinstance(obj, BaseItem)`` checks at + ``runtime_checkable`` enables ``isinstance(obj, TableauItem)`` checks at runtime, but these only verify attribute *presence*, not types or signatures. Full static checking requires a type checker such as mypy. @@ -38,19 +39,27 @@ class BaseItem(Protocol): four primary content classes have divergent signatures (different ``resp`` parameter types, extra parameters) that cannot be unified without widening to ``Any``. + + ``id`` and ``name`` are declared as read-only ``@property`` so that + concrete classes with narrower return types (e.g. ``name: str``) satisfy + the protocol under mypy's covariant property checking. Plain writable + instance attributes also satisfy a read-only property Protocol requirement. """ - id: str | None - name: str | None + @property + def id(self) -> str | None: ... + + @property + def name(self) -> str | None: ... @runtime_checkable -class OwnedItem(BaseItem, Protocol): +class OwnedItem(TableauItem, Protocol): """Structural interface for TSC items that carry an owner reference. Structurally satisfied by WorkbookItem, DatasourceItem, ViewItem, FlowItem, ProjectItem, and MetricItem -- every item class that exposes - an ``owner_id`` attribute. + an ``owner_id`` attribute. Extends ``TableauItem``. No concrete class needs to explicitly inherit from OwnedItem. Protocol structural subtyping means any class that exposes the required attribute @@ -67,7 +76,7 @@ def owner_id(self) -> str | None: ... @runtime_checkable -class TaggableItem(BaseItem, Protocol): +class TaggableItem(TableauItem, Protocol): """Structural interface for TSC items that carry a mutable tag set. Structurally satisfied by WorkbookItem, DatasourceItem, ViewItem, diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index 9d7404612..22de6b6af 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -1,14 +1,4 @@ -from typing import Union - -from tableauserverclient.models.database_item import DatabaseItem -from tableauserverclient.models.datasource_item import DatasourceItem -from tableauserverclient.models.flow_item import FlowItem -from tableauserverclient.models.project_item import ProjectItem -from tableauserverclient.models.table_item import TableItem -from tableauserverclient.models.view_item import ViewItem -from tableauserverclient.models.workbook_item import WorkbookItem -from tableauserverclient.models.metric_item import MetricItem -from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem +from tableauserverclient.models.base_item import TableauItem class Resource: @@ -25,19 +15,8 @@ class Resource: Workbook = "workbook" -# resource types that have permissions, can be renamed, etc -# todo: refactoring: should actually define TableauItem as an interface and let all these implement it -TableauItem = Union[ - DatasourceItem, - FlowItem, - MetricItem, - ProjectItem, - ViewItem, - WorkbookItem, - VirtualConnectionItem, - DatabaseItem, - TableItem, -] +# TableauItem is now a structural Protocol (base_item.py) rather than a Union type. +# Any class with id and name satisfies it implicitly -- no inheritance required. def plural_type(content_type: Resource | str) -> str: diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index b31437a70..9f82ecf72 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -6,7 +6,7 @@ from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError -from typing import Callable, TYPE_CHECKING +from typing import Callable, Protocol, TYPE_CHECKING from tableauserverclient.helpers.logging import logger @@ -15,6 +15,15 @@ from ..request_options import RequestOptions +class _PermissibleItem(Protocol): + """Private protocol for items that support the permissions population pattern.""" + + @property + def id(self) -> str | None: ... + + def _set_permissions(self, permissions: Callable) -> None: ... + + class _PermissionsEndpoint(Endpoint): """Adds permission model to another endpoint @@ -69,7 +78,7 @@ def delete(self, resource: TableauItem, rules: PermissionsRule | list[Permission logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}") - def populate(self, item: TableauItem): + def populate(self, item: _PermissibleItem): if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) diff --git a/test/test_protocols.py b/test/test_protocols.py index 10638c1a2..ae73791fc 100644 --- a/test/test_protocols.py +++ b/test/test_protocols.py @@ -9,62 +9,62 @@ import datetime import tableauserverclient as TSC -from tableauserverclient.models.base_item import BaseItem, ContentItem, OwnedItem, TaggableItem +from tableauserverclient.models.base_item import TableauItem, ContentItem, OwnedItem, TaggableItem # --------------------------------------------------------------------------- -# BaseItem: id + name +# TableauItem: id + name # --------------------------------------------------------------------------- -class TestBaseItem: - def test_workbook_satisfies_base_item(self): +class TestTableauItem: + def test_workbook_satisfies_tableau_item(self): item = TSC.WorkbookItem(project_id="p1", name="wb") - assert isinstance(item, BaseItem) + assert isinstance(item, TableauItem) - def test_datasource_satisfies_base_item(self): + def test_datasource_satisfies_tableau_item(self): item = TSC.DatasourceItem(project_id="p1", name="ds") - assert isinstance(item, BaseItem) + assert isinstance(item, TableauItem) - def test_view_satisfies_base_item(self): + def test_view_satisfies_tableau_item(self): item = TSC.ViewItem() - assert isinstance(item, BaseItem) + assert isinstance(item, TableauItem) - def test_flow_satisfies_base_item(self): + def test_flow_satisfies_tableau_item(self): item = TSC.FlowItem(project_id="p1", name="f") - assert isinstance(item, BaseItem) + assert isinstance(item, TableauItem) - def test_project_satisfies_base_item(self): + def test_project_satisfies_tableau_item(self): item = TSC.ProjectItem(name="proj") - assert isinstance(item, BaseItem) + assert isinstance(item, TableauItem) - def test_metric_satisfies_base_item(self): + def test_metric_satisfies_tableau_item(self): item = TSC.MetricItem() - assert isinstance(item, BaseItem) + assert isinstance(item, TableauItem) - def test_user_satisfies_base_item(self): + def test_user_satisfies_tableau_item(self): item = TSC.UserItem(name="u", site_role="Viewer") - assert isinstance(item, BaseItem) + assert isinstance(item, TableauItem) - def test_plain_object_with_id_and_name_satisfies_base_item(self): + def test_plain_object_with_id_and_name_satisfies_tableau_item(self): """Structural subtyping: any object with id and name suffices.""" class Minimal: id: str | None = None name: str | None = "x" - assert isinstance(Minimal(), BaseItem) + assert isinstance(Minimal(), TableauItem) - def test_object_missing_name_does_not_satisfy_base_item(self): + def test_object_missing_name_does_not_satisfy_tableau_item(self): class NoName: id: str | None = None - assert not isinstance(NoName(), BaseItem) + assert not isinstance(NoName(), TableauItem) - def test_object_missing_id_does_not_satisfy_base_item(self): + def test_object_missing_id_does_not_satisfy_tableau_item(self): class NoId: name: str | None = "x" - assert not isinstance(NoId(), BaseItem) + assert not isinstance(NoId(), TableauItem) # --------------------------------------------------------------------------- @@ -237,7 +237,7 @@ def owner_id(self) -> str | None: # --------------------------------------------------------------------------- -# Protocol hierarchy: ContentItem implies OwnedItem, TaggableItem, and BaseItem +# Protocol hierarchy: ContentItem implies OwnedItem, TaggableItem, and TableauItem # --------------------------------------------------------------------------- @@ -252,10 +252,10 @@ def test_content_item_satisfier_also_satisfies_taggable_item(self): item = TSC.WorkbookItem(project_id="p1", name="wb") assert isinstance(item, TaggableItem) - def test_content_item_satisfier_also_satisfies_base_item(self): + def test_content_item_satisfier_also_satisfies_tableau_item(self): item = TSC.WorkbookItem(project_id="p1", name="wb") - assert isinstance(item, BaseItem) + assert isinstance(item, TableauItem) - def test_owned_item_satisfier_also_satisfies_base_item(self): + def test_owned_item_satisfier_also_satisfies_tableau_item(self): item = TSC.ProjectItem(name="proj") - assert isinstance(item, BaseItem) + assert isinstance(item, TableauItem)