diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index aa28e0dbf..0e292338c 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -1,3 +1,4 @@ +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 @@ -43,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 @@ -55,6 +56,12 @@ from tableauserverclient.models.extract_item import ExtractItem __all__ = [ + # Structural protocols (base_item.py) + "TableauItem", + "ContentItem", + "OwnedItem", + "TaggableItem", + # Concrete model classes (alphabetical) "CollectionItem", "ColumnItem", "ConnectionCredentials", @@ -93,14 +100,12 @@ "ServerInfoItem", "SiteAuthConfiguration", "SiteItem", - "SiteOIDCConfiguration", "SubscriptionItem", "TableItem", "TableauAuth", "PersonalAccessTokenAuth", "JWTAuth", "Resource", - "TableauItem", "plural_type", "TagItem", "Target", diff --git a/tableauserverclient/models/base_item.py b/tableauserverclient/models/base_item.py new file mode 100644 index 000000000..d07644ead --- /dev/null +++ b/tableauserverclient/models/base_item.py @@ -0,0 +1,106 @@ +"""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 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, 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 ``TableauItem`` in its MRO -- any class + with matching attributes satisfies the protocol implicitly. + + Notes + ----- + ``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. + + ``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`` 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. + """ + + @property + def id(self) -> str | None: ... + + @property + def name(self) -> str | None: ... + + +@runtime_checkable +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. Extends ``TableauItem``. + + 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. + """ + + @property + def owner_id(self) -> str | None: ... + + +@runtime_checkable +class TaggableItem(TableauItem, 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. + """ + + 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 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 + 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 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/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/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) 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/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 72c7274c8..e9f778a7e 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 @@ -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 @@ -67,16 +79,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") diff --git a/test/test_protocols.py b/test/test_protocols.py new file mode 100644 index 000000000..ae73791fc --- /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 datetime + +import tableauserverclient as TSC +from tableauserverclient.models.base_item import TableauItem, ContentItem, OwnedItem, TaggableItem + +# --------------------------------------------------------------------------- +# TableauItem: id + name +# --------------------------------------------------------------------------- + + +class TestTableauItem: + def test_workbook_satisfies_tableau_item(self): + item = TSC.WorkbookItem(project_id="p1", name="wb") + assert isinstance(item, TableauItem) + + def test_datasource_satisfies_tableau_item(self): + item = TSC.DatasourceItem(project_id="p1", name="ds") + assert isinstance(item, TableauItem) + + def test_view_satisfies_tableau_item(self): + item = TSC.ViewItem() + assert isinstance(item, TableauItem) + + def test_flow_satisfies_tableau_item(self): + item = TSC.FlowItem(project_id="p1", name="f") + assert isinstance(item, TableauItem) + + def test_project_satisfies_tableau_item(self): + item = TSC.ProjectItem(name="proj") + assert isinstance(item, TableauItem) + + def test_metric_satisfies_tableau_item(self): + item = TSC.MetricItem() + assert isinstance(item, TableauItem) + + def test_user_satisfies_tableau_item(self): + item = TSC.UserItem(name="u", site_role="Viewer") + assert isinstance(item, TableauItem) + + 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(), TableauItem) + + def test_object_missing_name_does_not_satisfy_tableau_item(self): + class NoName: + id: str | None = None + + assert not isinstance(NoName(), TableauItem) + + def test_object_missing_id_does_not_satisfy_tableau_item(self): + class NoId: + name: str | None = "x" + + assert not isinstance(NoId(), TableauItem) + + +# --------------------------------------------------------------------------- +# 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): + 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 TableauItem +# --------------------------------------------------------------------------- + + +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_tableau_item(self): + item = TSC.WorkbookItem(project_id="p1", name="wb") + assert isinstance(item, TableauItem) + + def test_owned_item_satisfier_also_satisfies_tableau_item(self): + item = TSC.ProjectItem(name="proj") + assert isinstance(item, TableauItem)