Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion tableauserverclient/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
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
Expand Down Expand Up @@ -55,6 +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",
Expand Down Expand Up @@ -93,7 +100,6 @@
"ServerInfoItem",
"SiteAuthConfiguration",
"SiteItem",
"SiteOIDCConfiguration",
"SubscriptionItem",
"TableItem",
"TableauAuth",
Expand Down
97 changes: 97 additions & 0 deletions tableauserverclient/models/base_item.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""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 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.

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(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.
"""

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