From f87287e6acc5023707b026ea1eabcedf011d1953 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 25 Jun 2026 17:31:50 -0700 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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/7] 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/7] 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/7] 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"