Skip to content
11 changes: 8 additions & 3 deletions tableauserverclient/models/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -93,14 +100,12 @@
"ServerInfoItem",
"SiteAuthConfiguration",
"SiteItem",
"SiteOIDCConfiguration",
"SubscriptionItem",
"TableItem",
"TableauAuth",
"PersonalAccessTokenAuth",
"JWTAuth",
"Resource",
"TableauItem",
"plural_type",
"TagItem",
"Target",
Expand Down
106 changes: 106 additions & 0 deletions tableauserverclient/models/base_item.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion tableauserverclient/models/datasource_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 3 additions & 24 deletions tableauserverclient/models/tableau_types.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion tableauserverclient/models/workbook_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand Down
13 changes: 11 additions & 2 deletions tableauserverclient/server/endpoint/permissions_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down
30 changes: 16 additions & 14 deletions tableauserverclient/server/endpoint/resource_tagger.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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)

Expand All @@ -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}"

Expand All @@ -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
Expand All @@ -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")


Expand Down
Loading
Loading