diff --git a/.gitignore b/.gitignore index 92778cd81..b3b3ff80f 100644 --- a/.gitignore +++ b/.gitignore @@ -156,3 +156,4 @@ docs/_site/ docs/.jekyll-metadata docs/Gemfile.lock samples/credentials +.venv/ diff --git a/pyproject.toml b/pyproject.toml index 202aed968..3bf47ea23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers = [ repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["argparse", "black==23.7", "mock", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", +test = ["black==23.7", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", "requests-mock>=1.0,<2.0"] [tool.black] @@ -49,6 +49,8 @@ disable_error_code = [ files = ["tableauserverclient", "test"] show_error_codes = true ignore_missing_imports = true # defusedxml library has no types +no_implicit_reexport = true + [tool.pytest.ini_options] testpaths = ["test"] addopts = "--junitxml=./test.junit.xml" diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index f093f521b..91205d810 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,6 +1,6 @@ -from ._version import get_versions -from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE -from .models import ( +from tableauserverclient._version import get_versions +from tableauserverclient.namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE +from tableauserverclient.models import ( BackgroundJobItem, ColumnItem, ConnectionCredentials, @@ -43,7 +43,8 @@ WeeklyInterval, WorkbookItem, ) -from .server import ( + +from tableauserverclient.server import ( CSVRequestOptions, ExcelRequestOptions, ImageRequestOptions, @@ -57,3 +58,62 @@ Server, Sort, ) + +__all__ = [ + "get_versions", + "DEFAULT_NAMESPACE", + "BackgroundJobItem", + "BackgroundJobItem", + "ColumnItem", + "ConnectionCredentials", + "ConnectionItem", + "CustomViewItem", + "DQWItem", + "DailyInterval", + "DataAlertItem", + "DatabaseItem", + "DataFreshnessPolicyItem", + "DatasourceItem", + "FavoriteItem", + "FlowItem", + "FlowRunItem", + "FileuploadItem", + "GroupItem", + "HourlyInterval", + "IntervalItem", + "JobItem", + "JWTAuth", + "MetricItem", + "MonthlyInterval", + "PaginationItem", + "Permission", + "PermissionsRule", + "PersonalAccessTokenAuth", + "ProjectItem", + "RevisionItem", + "ScheduleItem", + "SiteItem", + "ServerInfoItem", + "SubscriptionItem", + "TableItem", + "TableauAuth", + "Target", + "TaskItem", + "UserItem", + "ViewItem", + "WebhookItem", + "WeeklyInterval", + "WorkbookItem", + "CSVRequestOptions", + "ExcelRequestOptions", + "ImageRequestOptions", + "PDFRequestOptions", + "RequestOptions", + "MissingRequiredFieldError", + "NotSignedInError", + "ServerResponseError", + "Filter", + "Pager", + "Server", + "Sort", +] diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index e7a853d9a..5fdf3c2c3 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -1,43 +1,94 @@ -from .column_item import ColumnItem -from .connection_credentials import ConnectionCredentials -from .connection_item import ConnectionItem -from .custom_view_item import CustomViewItem -from .data_acceleration_report_item import DataAccelerationReportItem -from .data_alert_item import DataAlertItem -from .database_item import DatabaseItem -from .data_freshness_policy_item import DataFreshnessPolicyItem -from .datasource_item import DatasourceItem -from .dqw_item import DQWItem -from .exceptions import UnpopulatedPropertyError -from .favorites_item import FavoriteItem -from .fileupload_item import FileuploadItem -from .flow_item import FlowItem -from .flow_run_item import FlowRunItem -from .group_item import GroupItem -from .interval_item import ( +from tableauserverclient.models.column_item import ColumnItem +from tableauserverclient.models.connection_credentials import ConnectionCredentials +from tableauserverclient.models.connection_item import ConnectionItem +from tableauserverclient.models.custom_view_item import CustomViewItem +from tableauserverclient.models.data_acceleration_report_item import DataAccelerationReportItem +from tableauserverclient.models.data_alert_item import DataAlertItem +from tableauserverclient.models.database_item import DatabaseItem +from tableauserverclient.models.data_freshness_policy_item import DataFreshnessPolicyItem +from tableauserverclient.models.datasource_item import DatasourceItem +from tableauserverclient.models.dqw_item import DQWItem +from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.favorites_item import FavoriteItem +from tableauserverclient.models.fileupload_item import FileuploadItem +from tableauserverclient.models.flow_item import FlowItem +from tableauserverclient.models.flow_run_item import FlowRunItem +from tableauserverclient.models.group_item import GroupItem +from tableauserverclient.models.interval_item import ( IntervalItem, DailyInterval, WeeklyInterval, MonthlyInterval, HourlyInterval, ) -from .job_item import JobItem, BackgroundJobItem -from .metric_item import MetricItem -from .pagination_item import PaginationItem -from .permissions_item import PermissionsRule, Permission -from .project_item import ProjectItem -from .revision_item import RevisionItem -from .schedule_item import ScheduleItem -from .server_info_item import ServerInfoItem -from .site_item import SiteItem -from .subscription_item import SubscriptionItem -from .table_item import TableItem -from .tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth, JWTAuth -from .tableau_types import Resource, TableauItem, plural_type -from .tag_item import TagItem -from .target import Target -from .task_item import TaskItem -from .user_item import UserItem -from .view_item import ViewItem -from .webhook_item import WebhookItem -from .workbook_item import WorkbookItem +from tableauserverclient.models.job_item import JobItem, BackgroundJobItem +from tableauserverclient.models.metric_item import MetricItem +from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.models.permissions_item import PermissionsRule, Permission +from tableauserverclient.models.project_item import ProjectItem +from tableauserverclient.models.revision_item import RevisionItem +from tableauserverclient.models.schedule_item import ScheduleItem +from tableauserverclient.models.server_info_item import ServerInfoItem +from tableauserverclient.models.site_item import SiteItem +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.tag_item import TagItem +from tableauserverclient.models.target import Target +from tableauserverclient.models.task_item import TaskItem +from tableauserverclient.models.user_item import UserItem +from tableauserverclient.models.view_item import ViewItem +from tableauserverclient.models.webhook_item import WebhookItem +from tableauserverclient.models.workbook_item import WorkbookItem + +__all__ = [ + "ColumnItem", + "ConnectionCredentials", + "ConnectionItem", + "Credentials", + "CustomViewItem", + "DataAccelerationReportItem", + "DataAlertItem", + "DatabaseItem", + "DataFreshnessPolicyItem", + "DatasourceItem", + "DQWItem", + "UnpopulatedPropertyError", + "FavoriteItem", + "FileuploadItem", + "FlowItem", + "FlowRunItem", + "GroupItem", + "IntervalItem", + "JobItem", + "DailyInterval", + "WeeklyInterval", + "MonthlyInterval", + "HourlyInterval", + "BackgroundJobItem", + "MetricItem", + "PaginationItem", + "Permission", + "PermissionsRule", + "ProjectItem", + "RevisionItem", + "ScheduleItem", + "ServerInfoItem", + "SiteItem", + "SubscriptionItem", + "TableItem", + "TableauAuth", + "PersonalAccessTokenAuth", + "JWTAuth", + "Resource", + "TableauItem", + "plural_type", + "TagItem", + "Target", + "TaskItem", + "UserItem", + "ViewItem", + "WebhookItem", + "WorkbookItem", +] diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 5a867135c..e4e71c4a2 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -6,16 +6,16 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -from .connection_item import ConnectionItem -from .exceptions import UnpopulatedPropertyError -from .permissions_item import PermissionsRule -from .property_decorators import ( +from tableauserverclient.models.connection_item import ConnectionItem +from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.permissions_item import PermissionsRule +from tableauserverclient.models.property_decorators import ( property_not_nullable, property_is_boolean, property_is_enum, ) -from .revision_item import RevisionItem -from .tag_item import TagItem +from tableauserverclient.models.revision_item import RevisionItem +from tableauserverclient.models.tag_item import TagItem class DatasourceItem(object): @@ -47,6 +47,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self._initial_tags: Set = set() self._project_name: Optional[str] = None self._revisions = None + self._size: Optional[int] = None self._updated_at = None self._use_remote_query_agent = None self._webpage_url = None @@ -182,6 +183,10 @@ def revisions(self) -> List[RevisionItem]: raise UnpopulatedPropertyError(error) return self._revisions() + @property + def size(self) -> Optional[int]: + return self._size + def _set_connections(self, connections): self._connections = connections @@ -217,6 +222,7 @@ def _parse_common_elements(self, datasource_xml, ns): updated_at, use_remote_query_agent, webpage_url, + size, ) = self._parse_element(datasource_xml, ns) self._set_values( ask_data_enablement, @@ -237,6 +243,7 @@ def _parse_common_elements(self, datasource_xml, ns): updated_at, use_remote_query_agent, webpage_url, + size, ) return self @@ -260,6 +267,7 @@ def _set_values( updated_at, use_remote_query_agent, webpage_url, + size, ): if ask_data_enablement is not None: self._ask_data_enablement = ask_data_enablement @@ -297,6 +305,8 @@ def _set_values( self._use_remote_query_agent = str(use_remote_query_agent).lower() == "true" if webpage_url: self._webpage_url = webpage_url + if size is not None: + self._size = int(size) @classmethod def from_response(cls, resp: str, ns: Dict) -> List["DatasourceItem"]: @@ -330,6 +340,7 @@ def _parse_element(datasource_xml: ET.Element, ns: Dict) -> Tuple: has_extracts = datasource_xml.get("hasExtracts", None) use_remote_query_agent = datasource_xml.get("useRemoteQueryAgent", None) webpage_url = datasource_xml.get("webpageUrl", None) + size = datasource_xml.get("size", None) tags = None tags_elem = datasource_xml.find(".//t:tags", namespaces=ns) @@ -372,4 +383,5 @@ def _parse_element(datasource_xml: ET.Element, ns: Dict) -> Tuple: updated_at, use_remote_query_agent, webpage_url, + size, ) diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index 987623404..caff755e3 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -1,14 +1,14 @@ import logging from defusedxml.ElementTree import fromstring -from .tableau_types import TableauItem +from tableauserverclient.models.tableau_types import TableauItem -from .datasource_item import DatasourceItem -from .flow_item import FlowItem -from .project_item import ProjectItem -from .metric_item import MetricItem -from .view_item import ViewItem -from .workbook_item import WorkbookItem +from tableauserverclient.models.datasource_item import DatasourceItem +from tableauserverclient.models.flow_item import FlowItem +from tableauserverclient.models.project_item import ProjectItem +from tableauserverclient.models.metric_item import MetricItem +from tableauserverclient.models.view_item import ViewItem +from tableauserverclient.models.workbook_item import WorkbookItem from typing import Dict, List from tableauserverclient.helpers.logging import logger diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index d2b2227db..fecdb9723 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -45,7 +45,7 @@ def __repr__(self): return "" -class PermissionsRule(object): +class PermissionsRule: def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> None: self.grantee = grantee self.capabilities = capabilities @@ -53,6 +53,51 @@ def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> def __repr__(self): return "".format(self.grantee, self.capabilities) + def __eq__(self, other: object) -> bool: + if not hasattr(other, "grantee") or not hasattr(other, "capabilities"): + return False + return self.grantee == other.grantee and self.capabilities == other.capabilities + + def __and__(self, other: "PermissionsRule") -> "PermissionsRule": + if self.grantee != other.grantee: + raise ValueError("Cannot AND two permissions rules with different grantees") + + if self.capabilities == other.capabilities: + return self + + capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) + new_capabilities = {} + for capability in capabilities: + if (self.capabilities.get(capability), other.capabilities.get(capability)) == ( + Permission.Mode.Allow, + Permission.Mode.Allow, + ): + new_capabilities[capability] = Permission.Mode.Allow + elif Permission.Mode.Deny in (self.capabilities.get(capability), other.capabilities.get(capability)): + new_capabilities[capability] = Permission.Mode.Deny + + return PermissionsRule(self.grantee, new_capabilities) + + def __or__(self, other: "PermissionsRule") -> "PermissionsRule": + if self.grantee != other.grantee: + raise ValueError("Cannot OR two permissions rules with different grantees") + + if self.capabilities == other.capabilities: + return self + + capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) + new_capabilities = {} + for capability in capabilities: + if Permission.Mode.Allow in (self.capabilities.get(capability), other.capabilities.get(capability)): + new_capabilities[capability] = Permission.Mode.Allow + elif (self.capabilities.get(capability), other.capabilities.get(capability)) == ( + Permission.Mode.Deny, + Permission.Mode.Deny, + ): + new_capabilities[capability] = Permission.Mode.Deny + + return PermissionsRule(self.grantee, new_capabilities) + @classmethod def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: parsed_response = fromstring(resp) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 4918f1a14..9fb382885 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -4,8 +4,8 @@ from defusedxml.ElementTree import fromstring -from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_is_enum, property_not_empty +from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.property_decorators import property_is_enum, property_not_empty class ProjectItem(object): @@ -34,6 +34,7 @@ def __init__( self.content_permissions: Optional[str] = content_permissions self.parent_id: Optional[str] = parent_id self._samples: Optional[bool] = samples + self._owner_id: Optional[str] = None self._permissions = None self._default_workbook_permissions = None @@ -119,7 +120,7 @@ def owner_id(self) -> Optional[str]: @owner_id.setter def owner_id(self, value: str) -> None: - raise NotImplementedError("REST API does not currently support updating project owner.") + self._owner_id = value def is_default(self): return self.name.lower() == "default" diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py index 6fc6b0c22..710548fcc 100644 --- a/tableauserverclient/models/reference_item.py +++ b/tableauserverclient/models/reference_item.py @@ -8,6 +8,11 @@ def __str__(self): __repr__ = __str__ + def __eq__(self, other: object) -> bool: + if not hasattr(other, "id") or not hasattr(other, "tag_name"): + return False + return (self.id == other.id) and (self.tag_name == other.tag_name) + @property def id(self): return self._id diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 9aca206d7..10cf58723 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -1,16 +1,20 @@ import abc +from typing import Dict, Optional class Credentials(abc.ABC): - def __init__(self, site_id=None, user_id_to_impersonate=None): + def __init__(self, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None) -> None: self.site_id = site_id or "" self.user_id_to_impersonate = user_id_to_impersonate or None @property @abc.abstractmethod - def credentials(self): - credentials = "Credentials can be username/password, Personal Access Token, or JWT" - +"This method returns values to set as an attribute on the credentials element of the request" + def credentials(self) -> Dict[str, str]: + credentials = ( + "Credentials can be username/password, Personal Access Token, or JWT" + "This method returns values to set as an attribute on the credentials element of the request" + ) + return {"key": "value"} @abc.abstractmethod def __repr__(self): @@ -28,10 +32,9 @@ def deprecate_site_attribute(): # The traditional auth type: username/password class TableauAuth(Credentials): - def __init__(self, username, password, site=None, site_id=None, user_id_to_impersonate=None): - if site is not None: - deprecate_site_attribute() - site_id = site + def __init__( + self, username: str, password: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None + ) -> None: super().__init__(site_id, user_id_to_impersonate) if password is None: raise TabError("Must provide a password when using traditional authentication") @@ -39,7 +42,7 @@ def __init__(self, username, password, site=None, site_id=None, user_id_to_imper self.username = username @property - def credentials(self): + def credentials(self) -> Dict[str, str]: return {"name": self.username, "password": self.password} def __repr__(self): @@ -49,20 +52,16 @@ def __repr__(self): uid = "" return f"" - @property - def site(self): - deprecate_site_attribute() - return self.site_id - - @site.setter - def site(self, value): - deprecate_site_attribute() - self.site_id = value - # A Tableau-generated Personal Access Token class PersonalAccessTokenAuth(Credentials): - def __init__(self, token_name, personal_access_token, site_id=None, user_id_to_impersonate=None): + def __init__( + self, + token_name: str, + personal_access_token: str, + site_id: Optional[str] = None, + user_id_to_impersonate: Optional[str] = None, + ) -> None: if personal_access_token is None or token_name is None: raise TabError("Must provide a token and token name when using PAT authentication") super().__init__(site_id=site_id, user_id_to_impersonate=user_id_to_impersonate) @@ -70,7 +69,7 @@ def __init__(self, token_name, personal_access_token, site_id=None, user_id_to_i self.personal_access_token = personal_access_token @property - def credentials(self): + def credentials(self) -> Dict[str, str]: return { "personalAccessTokenName": self.token_name, "personalAccessTokenSecret": self.personal_access_token, @@ -89,14 +88,14 @@ def __repr__(self): # A standard JWT generated specifically for Tableau class JWTAuth(Credentials): - def __init__(self, jwt: str, site_id=None, user_id_to_impersonate=None): + def __init__(self, jwt: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None) -> None: if jwt is None: raise TabError("Must provide a JWT token when using JWT authentication") super().__init__(site_id, user_id_to_impersonate) self.jwt = jwt @property - def credentials(self): + def credentials(self) -> Dict[str, str]: return {"jwt": self.jwt} def __repr__(self): diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 5abe19446..f5cd1d236 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -1,16 +1,91 @@ # These two imports must come first -from .request_factory import RequestFactory -from .request_options import ( +from tableauserverclient.server.request_factory import RequestFactory +from tableauserverclient.server.request_options import ( CSVRequestOptions, ExcelRequestOptions, ImageRequestOptions, PDFRequestOptions, RequestOptions, ) +from tableauserverclient.server.filter import Filter +from tableauserverclient.server.sort import Sort +from tableauserverclient.server.server import Server +from tableauserverclient.server.pager import Pager +from tableauserverclient.server.endpoint.exceptions import NotSignedInError -from .filter import Filter -from .sort import Sort -from .endpoint import * -from .server import Server -from .pager import Pager -from .endpoint.exceptions import NotSignedInError +from tableauserverclient.server.endpoint import ( + Auth, + CustomViews, + DataAccelerationReport, + DataAlerts, + Databases, + Datasources, + QuerysetEndpoint, + MissingRequiredFieldError, + Endpoint, + Favorites, + Fileuploads, + FlowRuns, + Flows, + FlowTasks, + Groups, + Jobs, + Metadata, + Metrics, + Projects, + Schedules, + ServerInfo, + ServerResponseError, + Sites, + Subscriptions, + Tables, + Tasks, + Users, + Views, + Webhooks, + Workbooks, +) + +__all__ = [ + "RequestFactory", + "CSVRequestOptions", + "ExcelRequestOptions", + "ImageRequestOptions", + "PDFRequestOptions", + "RequestOptions", + "Filter", + "Sort", + "Server", + "Pager", + "NotSignedInError", + "Auth", + "CustomViews", + "DataAccelerationReport", + "DataAlerts", + "Databases", + "Datasources", + "QuerysetEndpoint", + "MissingRequiredFieldError", + "Endpoint", + "Favorites", + "Fileuploads", + "FlowRuns", + "Flows", + "FlowTasks", + "Groups", + "Jobs", + "Metadata", + "Metrics", + "Projects", + "Schedules", + "ServerInfo", + "ServerResponseError", + "Sites", + "Subscriptions", + "Tables", + "Tasks", + "Users", + "Views", + "Webhooks", + "Workbooks", +] diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index b2f291369..024350aaa 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -1,28 +1,61 @@ -from .auth_endpoint import Auth -from .custom_views_endpoint import CustomViews -from .data_acceleration_report_endpoint import DataAccelerationReport -from .data_alert_endpoint import DataAlerts -from .databases_endpoint import Databases -from .datasources_endpoint import Datasources -from .endpoint import Endpoint, QuerysetEndpoint -from .exceptions import ServerResponseError, MissingRequiredFieldError -from .favorites_endpoint import Favorites -from .fileuploads_endpoint import Fileuploads -from .flow_runs_endpoint import FlowRuns -from .flows_endpoint import Flows -from .flow_task_endpoint import FlowTasks -from .groups_endpoint import Groups -from .jobs_endpoint import Jobs -from .metadata_endpoint import Metadata -from .metrics_endpoint import Metrics -from .projects_endpoint import Projects -from .schedules_endpoint import Schedules -from .server_info_endpoint import ServerInfo -from .sites_endpoint import Sites -from .subscriptions_endpoint import Subscriptions -from .tables_endpoint import Tables -from .tasks_endpoint import Tasks -from .users_endpoint import Users -from .views_endpoint import Views -from .webhooks_endpoint import Webhooks -from .workbooks_endpoint import Workbooks +from tableauserverclient.server.endpoint.auth_endpoint import Auth +from tableauserverclient.server.endpoint.custom_views_endpoint import CustomViews +from tableauserverclient.server.endpoint.data_acceleration_report_endpoint import DataAccelerationReport +from tableauserverclient.server.endpoint.data_alert_endpoint import DataAlerts +from tableauserverclient.server.endpoint.databases_endpoint import Databases +from tableauserverclient.server.endpoint.datasources_endpoint import Datasources +from tableauserverclient.server.endpoint.endpoint import Endpoint, QuerysetEndpoint +from tableauserverclient.server.endpoint.exceptions import ServerResponseError, MissingRequiredFieldError +from tableauserverclient.server.endpoint.favorites_endpoint import Favorites +from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads +from tableauserverclient.server.endpoint.flow_runs_endpoint import FlowRuns +from tableauserverclient.server.endpoint.flows_endpoint import Flows +from tableauserverclient.server.endpoint.flow_task_endpoint import FlowTasks +from tableauserverclient.server.endpoint.groups_endpoint import Groups +from tableauserverclient.server.endpoint.jobs_endpoint import Jobs +from tableauserverclient.server.endpoint.metadata_endpoint import Metadata +from tableauserverclient.server.endpoint.metrics_endpoint import Metrics +from tableauserverclient.server.endpoint.projects_endpoint import Projects +from tableauserverclient.server.endpoint.schedules_endpoint import Schedules +from tableauserverclient.server.endpoint.server_info_endpoint import ServerInfo +from tableauserverclient.server.endpoint.sites_endpoint import Sites +from tableauserverclient.server.endpoint.subscriptions_endpoint import Subscriptions +from tableauserverclient.server.endpoint.tables_endpoint import Tables +from tableauserverclient.server.endpoint.tasks_endpoint import Tasks +from tableauserverclient.server.endpoint.users_endpoint import Users +from tableauserverclient.server.endpoint.views_endpoint import Views +from tableauserverclient.server.endpoint.webhooks_endpoint import Webhooks +from tableauserverclient.server.endpoint.workbooks_endpoint import Workbooks + +__all__ = [ + "Auth", + "CustomViews", + "DataAccelerationReport", + "DataAlerts", + "Databases", + "Datasources", + "QuerysetEndpoint", + "MissingRequiredFieldError", + "Endpoint", + "Favorites", + "Fileuploads", + "FlowRuns", + "Flows", + "FlowTasks", + "Groups", + "Jobs", + "Metadata", + "Metrics", + "Projects", + "Schedules", + "ServerInfo", + "ServerResponseError", + "Sites", + "Subscriptions", + "Tables", + "Tasks", + "Users", + "Views", + "Webhooks", + "Workbooks", +] diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index 119580609..d1446b1fe 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -17,7 +17,7 @@ """ -class CustomViews(QuerysetEndpoint): +class CustomViews(QuerysetEndpoint[CustomViewItem]): def __init__(self, parent_srv): super(CustomViews, self).__init__(parent_srv) diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 125996277..849072a17 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -88,17 +88,6 @@ def _get_tables_for_database(self, database_item): def populate_permissions(self, item): self._permissions.populate(item) - @api(version="3.5") - def update_permission(self, item, rules): - import warnings - - warnings.warn( - "Server.databases.update_permission is deprecated, " - "please use Server.databases.update_permissions instead.", - DeprecationWarning, - ) - return self._permissions.update(item, rules) - @api(version="3.5") def update_permissions(self, item, rules): return self._permissions.update(item, rules) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 28226d280..316f078a2 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -15,11 +15,11 @@ from tableauserverclient.models import PermissionsRule from .schedules_endpoint import AddResponse -from .dqw_endpoint import _DataQualityWarningEndpoint -from .endpoint import QuerysetEndpoint, api, parameter_added_in -from .exceptions import InternalServerError, MissingRequiredFieldError -from .permissions_endpoint import _PermissionsEndpoint -from .resource_tagger import _ResourceTagger +from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in +from tableauserverclient.server.endpoint.exceptions import InternalServerError, MissingRequiredFieldError +from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.resource_tagger import _ResourceTagger from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, CHUNK_SIZE_MB from tableauserverclient.filesys_helpers import ( @@ -54,7 +54,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Datasources(QuerysetEndpoint): +class Datasources(QuerysetEndpoint[DatasourceItem]): def __init__(self, parent_srv: "Server") -> None: super(Datasources, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) @@ -126,9 +126,13 @@ def download( datasource_id: str, filepath: Optional[PathOrFileW] = None, include_extract: bool = True, - no_extract: Optional[bool] = None, ) -> str: - return self.download_revision(datasource_id, None, filepath, include_extract, no_extract) + return self.download_revision( + datasource_id, + None, + filepath, + include_extract, + ) # Update datasource @api(version="2.0") @@ -351,17 +355,6 @@ def update_hyper_data( def populate_permissions(self, item: DatasourceItem) -> None: self._permissions.populate(item) - @api(version="2.0") - def update_permission(self, item, permission_item): - import warnings - - warnings.warn( - "Server.datasources.update_permission is deprecated, " - "please use Server.datasources.update_permissions instead.", - DeprecationWarning, - ) - self._permissions.update(item, permission_item) - @api(version="2.0") def update_permissions(self, item: DatasourceItem, permission_item: List["PermissionsRule"]) -> None: self._permissions.update(item, permission_item) @@ -415,7 +408,6 @@ def download_revision( revision_number: str, filepath: Optional[PathOrFileW] = None, include_extract: bool = True, - no_extract: Optional[bool] = None, ) -> PathOrFileW: if not datasource_id: error = "Datasource ID undefined." @@ -424,14 +416,6 @@ def download_revision( url = "{0}/{1}/content".format(self.baseurl, datasource_id) else: url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, datasource_id, revision_number) - if no_extract is False or no_extract is True: - import warnings - - warnings.warn( - "no_extract is deprecated, use include_extract instead.", - DeprecationWarning, - ) - include_extract = not no_extract if not include_extract: url += "?includeExtract=False" diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 2b7f57069..6b29e736a 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,9 +1,13 @@ from tableauserverclient import datetime_helpers as datetime +import abc from packaging.version import Version from functools import wraps from xml.etree.ElementTree import ParseError -from typing import Any, Callable, Dict, Optional, TYPE_CHECKING, Union +from typing import Any, Callable, Dict, Generic, List, Optional, TYPE_CHECKING, Tuple, TypeVar, Union + +from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.server.request_options import RequestOptions from .exceptions import ( ServerResponseError, @@ -300,25 +304,36 @@ def wrapper(self, *args, **kwargs): return _decorator -class QuerysetEndpoint(Endpoint): +T = TypeVar("T") + + +class QuerysetEndpoint(Endpoint, Generic[T]): @api(version="2.0") - def all(self, *args, **kwargs): - queryset = QuerySet(self) + def all(self, *args, page_size: Optional[int] = None, **kwargs) -> QuerySet[T]: + if args or kwargs: + raise ValueError(".all method takes no arguments.") + queryset = QuerySet(self, page_size=page_size) return queryset @api(version="2.0") - def filter(self, *_, **kwargs) -> QuerySet: + def filter(self, *_, page_size: Optional[int] = None, **kwargs) -> QuerySet[T]: if _: raise RuntimeError("Only keyword arguments accepted.") - queryset = QuerySet(self).filter(**kwargs) + queryset = QuerySet(self, page_size=page_size).filter(**kwargs) return queryset @api(version="2.0") - def order_by(self, *args, **kwargs): + def order_by(self, *args, **kwargs) -> QuerySet[T]: + if kwargs: + raise ValueError(".order_by does not accept keyword arguments.") queryset = QuerySet(self).order_by(*args) return queryset @api(version="2.0") - def paginate(self, **kwargs): + def paginate(self, **kwargs) -> QuerySet[T]: queryset = QuerySet(self).paginate(**kwargs) return queryset + + @abc.abstractmethod + def get(self, request_options: RequestOptions) -> Tuple[List[T], PaginationItem]: + raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}") diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index f82b1b3d5..5f298f37e 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,4 +1,4 @@ -from .endpoint import Endpoint, api +from tableauserverclient.server.endpoint.endpoint import Endpoint, api from requests import Response from tableauserverclient.helpers.logging import logger from tableauserverclient.models import ( diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 63b32e006..ea45ce802 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -13,7 +13,7 @@ from ..request_options import RequestOptions -class FlowRuns(QuerysetEndpoint): +class FlowRuns(QuerysetEndpoint[FlowRunItem]): def __init__(self, parent_srv: "Server") -> None: super(FlowRuns, self).__init__(parent_srv) return None diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 77b01c478..2997e9456 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -50,7 +50,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Flows(QuerysetEndpoint): +class Flows(QuerysetEndpoint[FlowItem]): def __init__(self, parent_srv): super(Flows, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) @@ -265,16 +265,6 @@ def publish( def populate_permissions(self, item: FlowItem) -> None: self._permissions.populate(item) - @api(version="3.3") - def update_permission(self, item, permission_item): - import warnings - - warnings.warn( - "Server.flows.update_permission is deprecated, " "please use Server.flows.update_permissions instead.", - DeprecationWarning, - ) - self._permissions.update(item, permission_item) - @api(version="3.3") def update_permissions(self, item: FlowItem, permission_item: Iterable["PermissionsRule"]) -> None: self._permissions.update(item, permission_item) diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index ab5f672d1..2ee9fe0ab 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -14,7 +14,7 @@ from ..request_options import RequestOptions -class Groups(QuerysetEndpoint): +class Groups(QuerysetEndpoint[GroupItem]): @property def baseurl(self) -> str: return "{0}/sites/{1}/groups".format(self.parent_srv.baseurl, self.parent_srv.site_id) @@ -67,21 +67,7 @@ def delete(self, group_id: str) -> None: logger.info("Deleted single group (ID: {0})".format(group_id)) @api(version="2.0") - def update( - self, group_item: GroupItem, default_site_role: Optional[str] = None, as_job: bool = False - ) -> Union[GroupItem, JobItem]: - # (1/8/2021): Deprecated starting v0.15 - if default_site_role is not None: - import warnings - - warnings.simplefilter("always", DeprecationWarning) - warnings.warn( - 'Groups.update(...default_site_role=""...) is deprecated, ' - "please set the minimum_site_role field of GroupItem", - DeprecationWarning, - ) - group_item.minimum_site_role = default_site_role - + def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem, JobItem]: url = "{0}/{1}".format(self.baseurl, group_item.id) if not group_item.id: @@ -93,7 +79,7 @@ def update( elif as_job: url = "?".join([url, "asJob=True"]) - update_req = RequestFactory.Group.update_req(group_item, None) + update_req = RequestFactory.Group.update_req(group_item) server_response = self.put_request(url, update_req) logger.info("Updated group item (ID: {0})".format(group_item.id)) if as_job: diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index d0b865e21..74770e22b 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -11,7 +11,7 @@ from typing import List, Optional, Tuple, Union -class Jobs(QuerysetEndpoint): +class Jobs(QuerysetEndpoint[JobItem]): @property def baseurl(self): return "{0}/sites/{1}/jobs".format(self.parent_srv.baseurl, self.parent_srv.site_id) diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index 39146d062..38c3eebb6 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -42,9 +42,9 @@ def extract(obj, arr, key): def get_page_info(result): - next_page = extract_values(result, "hasNextPage").pop() - cursor = extract_values(result, "endCursor").pop() - return next_page, cursor + next_page = extract_values(result, "hasNextPage") + cursor = extract_values(result, "endCursor") + return next_page.pop() if next_page else None, cursor.pop() if cursor else None class Metadata(Endpoint): diff --git a/tableauserverclient/server/endpoint/metrics_endpoint.py b/tableauserverclient/server/endpoint/metrics_endpoint.py index a0e984475..ab1ec5852 100644 --- a/tableauserverclient/server/endpoint/metrics_endpoint.py +++ b/tableauserverclient/server/endpoint/metrics_endpoint.py @@ -18,7 +18,7 @@ from tableauserverclient.helpers.logging import logger -class Metrics(QuerysetEndpoint): +class Metrics(QuerysetEndpoint[MetricItem]): def __init__(self, parent_srv: "Server") -> None: super(Metrics, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 99bb2e39b..259f53b14 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -1,22 +1,22 @@ import logging -from .default_permissions_endpoint import _DefaultPermissionsEndpoint -from .endpoint import QuerysetEndpoint, api, XML_CONTENT_TYPE -from .exceptions import MissingRequiredFieldError -from .permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.default_permissions_endpoint import _DefaultPermissionsEndpoint +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, XML_CONTENT_TYPE +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError +from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server import RequestFactory, RequestOptions from tableauserverclient.models import ProjectItem, PaginationItem, Resource from typing import List, Optional, Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ..server import Server - from ..request_options import RequestOptions + from tableauserverclient.server.server import Server + from tableauserverclient.server.request_options import RequestOptions from tableauserverclient.helpers.logging import logger -class Projects(QuerysetEndpoint): +class Projects(QuerysetEndpoint[ProjectItem]): def __init__(self, parent_srv: "Server") -> None: super(Projects, self).__init__(parent_srv) @@ -75,17 +75,6 @@ def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte def populate_permissions(self, item: ProjectItem) -> None: self._permissions.populate(item) - @api(version="2.0") - def update_permission(self, item, rules): - import warnings - - warnings.warn( - "Server.projects.update_permission is deprecated, " - "please use Server.projects.update_permissions instead.", - DeprecationWarning, - ) - return self._permissions.update(item, rules) - @api(version="2.0") def update_permissions(self, item, rules): return self._permissions.update(item, rules) diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index dfb2e6d7c..b4c5181e9 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -101,16 +101,6 @@ def update_column(self, table_item, column_item): def populate_permissions(self, item): self._permissions.populate(item) - @api(version="3.5") - def update_permission(self, item, rules): - import warnings - - warnings.warn( - "Server.tables.update_permission is deprecated, " "please use Server.tables.update_permissions instead.", - DeprecationWarning, - ) - return self._permissions.update(item, rules) - @api(version="3.5") def update_permissions(self, item, rules): return self._permissions.update(item, rules) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index e8c5cc962..a84ca7399 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -11,7 +11,7 @@ from tableauserverclient.helpers.logging import logger -class Users(QuerysetEndpoint): +class Users(QuerysetEndpoint[UserItem]): @property def baseurl(self) -> str: return "{0}/sites/{1}/users".format(self.parent_srv.baseurl, self.parent_srv.site_id) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 9c4b90657..f98eb1cd7 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -21,7 +21,7 @@ ) -class Views(QuerysetEndpoint): +class Views(QuerysetEndpoint[ViewItem]): def __init__(self, parent_srv): super(Views, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) @@ -50,12 +50,14 @@ def get( return all_view_items, pagination_item @api(version="3.1") - def get_by_id(self, view_id: str) -> ViewItem: + def get_by_id(self, view_id: str, usage: bool = False) -> ViewItem: if not view_id: error = "View item missing ID." raise MissingRequiredFieldError(error) logger.info("Querying single view (ID: {0})".format(view_id)) url = "{0}/{1}".format(self.baseurl, view_id) + if usage: + url += "?includeUsageStatistics=true" server_response = self.get_request(url) return ViewItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index bc535b2d6..30f8ce036 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -56,7 +56,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Workbooks(QuerysetEndpoint): +class Workbooks(QuerysetEndpoint[WorkbookItem]): def __init__(self, parent_srv: "Server") -> None: super(Workbooks, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) @@ -160,13 +160,6 @@ def update( updated_workbook = copy.copy(workbook_item) return updated_workbook._parse_common_tags(server_response.content, self.parent_srv.namespace) - @api(version="2.3") - def update_conn(self, *args, **kwargs): - import warnings - - warnings.warn("update_conn is deprecated, please use update_connection instead") - return self.update_connection(*args, **kwargs) - # Update workbook_connection @api(version="2.3") def update_connection(self, workbook_item: WorkbookItem, connection_item: ConnectionItem) -> ConnectionItem: @@ -189,9 +182,13 @@ def download( workbook_id: str, filepath: Optional[PathOrFileW] = None, include_extract: bool = True, - no_extract: Optional[bool] = None, ) -> str: - return self.download_revision(workbook_id, None, filepath, include_extract, no_extract) + return self.download_revision( + workbook_id, + None, + filepath, + include_extract, + ) # Get all views of workbook @api(version="2.0") @@ -315,21 +312,11 @@ def publish( workbook_item: WorkbookItem, file: PathOrFileR, mode: str, - connection_credentials: Optional["ConnectionCredentials"] = None, connections: Optional[Sequence[ConnectionItem]] = None, as_job: bool = False, - hidden_views: Optional[Sequence[str]] = None, skip_connection_check: bool = False, parameters=None, ): - if connection_credentials is not None: - import warnings - - warnings.warn( - "connection_credentials is being deprecated. Use connections instead", - DeprecationWarning, - ) - if isinstance(file, (str, os.PathLike)): if not os.path.isfile(file): error = "File path does not lead to an existing file." @@ -391,12 +378,9 @@ def publish( logger.info("Publishing {0} to server with chunking method (workbook over 64MB)".format(workbook_item.name)) upload_session_id = self.parent_srv.fileuploads.upload(file) url = "{0}&uploadSessionId={1}".format(url, upload_session_id) - conn_creds = connection_credentials xml_request, content_type = RequestFactory.Workbook.publish_req_chunked( workbook_item, - connection_credentials=conn_creds, connections=connections, - hidden_views=hidden_views, ) else: logger.info("Publishing {0} to server".format(filename)) @@ -411,14 +395,11 @@ def publish( else: raise TypeError("file should be a filepath or file object.") - conn_creds = connection_credentials xml_request, content_type = RequestFactory.Workbook.publish_req( workbook_item, filename, file_contents, - connection_credentials=conn_creds, connections=connections, - hidden_views=hidden_views, ) logger.debug("Request xml: {0} ".format(redact_xml(xml_request[:1000]))) @@ -468,7 +449,6 @@ def download_revision( revision_number: Optional[str], filepath: Optional[PathOrFileW] = None, include_extract: bool = True, - no_extract: Optional[bool] = None, ) -> PathOrFileW: if not workbook_id: error = "Workbook ID undefined." @@ -478,15 +458,6 @@ def download_revision( else: url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, workbook_id, revision_number) - if no_extract is False or no_extract is True: - import warnings - - warnings.warn( - "no_extract is deprecated, use include_extract instead.", - DeprecationWarning, - ) - include_extract = not no_extract - if not include_extract: url += "?includeExtract=False" diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index 3220f5372..fede56012 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,9 +1,28 @@ +import copy from functools import partial +from typing import Generic, Iterable, Iterator, List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable -from . import RequestOptions +from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.server.request_options import RequestOptions -class Pager(object): +T = TypeVar("T") +ReturnType = Tuple[List[T], PaginationItem] + + +@runtime_checkable +class Endpoint(Protocol): + def get(self, req_options: Optional[RequestOptions], **kwargs) -> ReturnType: + ... + + +@runtime_checkable +class CallableEndpoint(Protocol): + def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> ReturnType: + ... + + +class Pager(Iterable[T]): """ Generator that takes an endpoint (top level endpoints with `.get)` and lazily loads items from Server. Supports all `RequestOptions` including starting on any page. Also used by models to load sub-models @@ -12,12 +31,17 @@ class Pager(object): Will loop over anything that returns (List[ModelItem], PaginationItem). """ - def __init__(self, endpoint, request_opts=None, **kwargs): - if hasattr(endpoint, "get"): + def __init__( + self, + endpoint: Union[CallableEndpoint, Endpoint], + request_opts: Optional[RequestOptions] = None, + **kwargs, + ) -> None: + if isinstance(endpoint, Endpoint): # The simpliest case is to take an Endpoint and call its get endpoint = partial(endpoint.get, **kwargs) self._endpoint = endpoint - elif callable(endpoint): + elif isinstance(endpoint, CallableEndpoint): # but if they pass a callable then use that instead (used internally) endpoint = partial(endpoint, **kwargs) self._endpoint = endpoint @@ -25,47 +49,24 @@ def __init__(self, endpoint, request_opts=None, **kwargs): # Didn't get something we can page over raise ValueError("Pager needs a server endpoint to page through.") - self._options = request_opts + self._options = request_opts or RequestOptions() - # If we have options we could be starting on any page, backfill the count - if self._options: - self._count = (self._options.pagenumber - 1) * self._options.pagesize - else: - self._count = 0 - self._options = RequestOptions() - - def __iter__(self): - # Fetch the first page - current_item_list, last_pagination_item = self._endpoint(self._options) - - if last_pagination_item.total_available is None: - # This endpoint does not support pagination, drain the list and return - while current_item_list: - yield current_item_list.pop(0) - - return - - # Get the rest on demand as a generator - while self._count < last_pagination_item.total_available: - if ( - len(current_item_list) == 0 - and (last_pagination_item.page_number * last_pagination_item.page_size) - < last_pagination_item.total_available - ): - current_item_list, last_pagination_item = self._load_next_page(last_pagination_item) - - try: - yield current_item_list.pop(0) - self._count += 1 - - except IndexError: - # The total count on Server changed while fetching exit gracefully + def __iter__(self) -> Iterator[T]: + options = copy.deepcopy(self._options) + while True: + # Fetch the first page + current_item_list, pagination_item = self._endpoint(options) + + if pagination_item.total_available is None: + # This endpoint does not support pagination, drain the list and return + yield from current_item_list + return + yield from current_item_list + + if pagination_item.page_size * pagination_item.page_number >= pagination_item.total_available: + # Last page, exit return - def _load_next_page(self, last_pagination_item): - next_page = last_pagination_item.page_number + 1 - opts = RequestOptions(pagenumber=next_page, pagesize=last_pagination_item.page_size) - if self._options is not None: - opts.sort, opts.filter = self._options.sort, self._options.filter - current_item_list, last_pagination_item = self._endpoint(opts) - return current_item_list, last_pagination_item + # Update the options to fetch the next page + options.pagenumber = pagination_item.page_number + 1 + options.pagesize = pagination_item.page_size diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index c5613b2d6..195139269 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,9 +1,25 @@ -from typing import Tuple -from .filter import Filter -from .request_options import RequestOptions -from .sort import Sort +from collections.abc import Sized +from itertools import count +from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TYPE_CHECKING, TypeVar, overload +from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.server.filter import Filter +from tableauserverclient.server.request_options import RequestOptions +from tableauserverclient.server.sort import Sort import math +from typing_extensions import Self + +if TYPE_CHECKING: + from tableauserverclient.server.endpoint import QuerysetEndpoint + +T = TypeVar("T") + + +class Slice(Protocol): + start: Optional[int] + step: Optional[int] + stop: Optional[int] + def to_camel_case(word: str) -> str: return word.split("_")[0] + "".join(x.capitalize() or "_" for x in word.split("_")[1:]) @@ -16,28 +32,35 @@ def to_camel_case(word: str) -> str: """ -class QuerySet: - def __init__(self, model): +class QuerySet(Iterable[T], Sized): + def __init__(self, model: "QuerysetEndpoint[T]", page_size: Optional[int] = None) -> None: self.model = model - self.request_options = RequestOptions() - self._result_cache = None - self._pagination_item = None + self.request_options = RequestOptions(pagesize=page_size or 100) + self._result_cache: List[T] = [] + self._pagination_item = PaginationItem() - def __iter__(self): + def __iter__(self: Self) -> Iterator[T]: # Not built to be re-entrant. Starts back at page 1, and empties - # the result cache. - self.request_options.pagenumber = 1 - self._result_cache = None - total = self.total_available - size = self.page_size - yield from self._result_cache + # the result cache. Ensure the result_cache is empty to not yield + # items from prior usage. + self._result_cache = [] - # Loop through the subsequent pages. - for page in range(1, math.ceil(total / size)): - self.request_options.pagenumber = page + 1 - self._result_cache = None + for page in count(1): + self.request_options.pagenumber = page + self._result_cache = [] self._fetch_all() yield from self._result_cache + # Set result_cache to empty so the fetch will populate + if (page * self.page_size) >= len(self): + return + + @overload + def __getitem__(self: Self, k: Slice) -> List[T]: + ... + + @overload + def __getitem__(self: Self, k: int) -> T: + ... def __getitem__(self, k): page = self.page_number @@ -78,7 +101,7 @@ def __getitem__(self, k): return self._result_cache[k % size] elif k in range(self.total_available): # Otherwise, check if k is even sensible to return - self._result_cache = None + self._result_cache = [] # Add one to k, otherwise it gets stuck at page boundaries, e.g. 100 self.request_options.pagenumber = max(1, math.ceil((k + 1) / size)) return self[k] @@ -86,53 +109,57 @@ def __getitem__(self, k): # If k is unreasonable, raise an IndexError. raise IndexError - def _fetch_all(self): + def _fetch_all(self: Self) -> None: """ Retrieve the data and store result and pagination item in cache """ - if self._result_cache is None: + if not self._result_cache: self._result_cache, self._pagination_item = self.model.get(self.request_options) - def __len__(self) -> int: + def __len__(self: Self) -> int: return self.total_available @property - def total_available(self) -> int: + def total_available(self: Self) -> int: self._fetch_all() return self._pagination_item.total_available @property - def page_number(self) -> int: + def page_number(self: Self) -> int: self._fetch_all() return self._pagination_item.page_number @property - def page_size(self) -> int: + def page_size(self: Self) -> int: self._fetch_all() return self._pagination_item.page_size - def filter(self, *invalid, **kwargs): + def filter(self: Self, *invalid, page_size: Optional[int] = None, **kwargs) -> Self: if invalid: - raise RuntimeError(f"Only accepts keyword arguments.") + raise RuntimeError("Only accepts keyword arguments.") for kwarg_key, value in kwargs.items(): field_name, operator = self._parse_shorthand_filter(kwarg_key) self.request_options.filter.add(Filter(field_name, operator, value)) + + if page_size: + self.request_options.pagesize = page_size return self - def order_by(self, *args): + def order_by(self: Self, *args) -> Self: for arg in args: field_name, direction = self._parse_shorthand_sort(arg) self.request_options.sort.add(Sort(field_name, direction)) return self - def paginate(self, **kwargs): + def paginate(self: Self, **kwargs) -> Self: if "page_number" in kwargs: self.request_options.pagenumber = kwargs["page_number"] if "page_size" in kwargs: self.request_options.pagesize = kwargs["page_size"] return self - def _parse_shorthand_filter(self, key: str) -> Tuple[str, str]: + @staticmethod + def _parse_shorthand_filter(key: str) -> Tuple[str, str]: tokens = key.split("__", 1) if len(tokens) == 1: operator = RequestOptions.Operator.Equals @@ -146,7 +173,8 @@ def _parse_shorthand_filter(self, key: str) -> Tuple[str, str]: raise ValueError("Field name `{}` is not valid.".format(field)) return (field, operator) - def _parse_shorthand_sort(self, key: str) -> Tuple[str, str]: + @staticmethod + def _parse_shorthand_sort(key: str) -> Tuple[str, str]: direction = RequestOptions.Direction.Asc if key.startswith("-"): direction = RequestOptions.Direction.Desc diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index c204e7217..87438ecde 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -418,19 +418,10 @@ def create_ad_req(self, group_item: GroupItem) -> bytes: import_element.attrib["siteRole"] = group_item.minimum_site_role return ET.tostring(xml_request) - def update_req(self, group_item: GroupItem, default_site_role: Optional[str] = None) -> bytes: - # (1/8/2021): Deprecated starting v0.15 - if default_site_role is not None: - import warnings - - warnings.simplefilter("always", DeprecationWarning) - warnings.warn( - 'RequestFactory.Group.update_req(...default_site_role="") is deprecated, ' - "please set the minimum_site_role field of GroupItem", - DeprecationWarning, - ) - group_item.minimum_site_role = default_site_role - + def update_req( + self, + group_item: GroupItem, + ) -> bytes: xml_request = ET.Element("tsRequest") group_element = ET.SubElement(xml_request, "group") @@ -491,6 +482,9 @@ def update_req(self, project_item: "ProjectItem") -> bytes: project_element.attrib["contentPermissions"] = project_item.content_permissions if project_item.parent_id is not None: project_element.attrib["parentProjectId"] = project_item.parent_id + if (owner := project_item.owner_id) is not None: + owner_element = ET.SubElement(project_element, "owner") + owner_element.attrib["id"] = owner return ET.tostring(xml_request) def create_req(self, project_item: "ProjectItem") -> bytes: @@ -893,9 +887,7 @@ class WorkbookRequest(object): def _generate_xml( self, workbook_item, - connection_credentials=None, connections=None, - hidden_views=None, ): xml_request = ET.Element("tsRequest") workbook_element = ET.SubElement(xml_request, "workbook") @@ -905,12 +897,6 @@ def _generate_xml( project_element = ET.SubElement(workbook_element, "project") project_element.attrib["id"] = str(workbook_item.project_id) - if connection_credentials is not None and connections is not None: - raise RuntimeError("You cannot set both `connections` and `connection_credentials`") - - if connection_credentials is not None and connection_credentials != False: - _add_credentials_element(workbook_element, connection_credentials) - if connections is not None and connections != False and len(connections) > 0: connections_element = ET.SubElement(workbook_element, "connections") for connection in connections: @@ -919,17 +905,6 @@ def _generate_xml( if workbook_item.description is not None: workbook_element.attrib["description"] = workbook_item.description - if hidden_views is not None: - import warnings - - warnings.simplefilter("always", DeprecationWarning) - warnings.warn( - "the hidden_views parameter should now be set on the workbook directly", - DeprecationWarning, - ) - if workbook_item.hidden_views is None: - workbook_item.hidden_views = hidden_views - if workbook_item.hidden_views is not None: views_element = ET.SubElement(workbook_element, "views") for view_name in workbook_item.hidden_views: @@ -1012,15 +987,11 @@ def publish_req( workbook_item, filename, file_contents, - connection_credentials=None, connections=None, - hidden_views=None, ): xml_request = self._generate_xml( workbook_item, - connection_credentials=connection_credentials, connections=connections, - hidden_views=hidden_views, ) parts = { @@ -1032,15 +1003,11 @@ def publish_req( def publish_req_chunked( self, workbook_item, - connection_credentials=None, connections=None, - hidden_views=None, ): xml_request = self._generate_xml( workbook_item, - connection_credentials=connection_credentials, connections=connections, - hidden_views=hidden_views, ) parts = {"request_payload": ("", xml_request, "text/xml")} @@ -1061,8 +1028,13 @@ class Connection(object): @_tsrequest_wrapped def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") -> None: connection_element = ET.SubElement(xml_request, "connection") - if connection_item.server_address is not None: - connection_element.attrib["serverAddress"] = connection_item.server_address.lower() + if (server_address := connection_item.server_address) is not None: + if (conn_type := connection_item.connection_type) is not None: + if conn_type.casefold() != "odata".casefold(): + server_address = server_address.lower() + else: + server_address = server_address.lower() + connection_element.attrib["serverAddress"] = server_address if connection_item.server_port is not None: connection_element.attrib["serverPort"] = str(connection_item.server_port) if connection_item.username is not None: diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 3a6831458..10b1a53ad 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -5,9 +5,7 @@ from defusedxml.ElementTree import fromstring, ParseError from packaging.version import Version - -from . import CustomViews -from .endpoint import ( +from tableauserverclient.server.endpoint import ( Sites, Views, Users, @@ -34,13 +32,14 @@ FlowRuns, Metrics, Endpoint, + CustomViews, ) -from .exceptions import ( +from tableauserverclient.server.exceptions import ( ServerInfoEndpointNotFoundError, EndpointUnavailableError, ) -from .endpoint.exceptions import NotSignedInError -from ..namespace import Namespace +from tableauserverclient.server.endpoint.exceptions import NotSignedInError +from tableauserverclient.namespace import Namespace _PRODUCT_TO_REST_VERSION = { diff --git a/test/assets/datasource_get.xml b/test/assets/datasource_get.xml index 951409caa..1c420d116 100644 --- a/test/assets/datasource_get.xml +++ b/test/assets/datasource_get.xml @@ -2,12 +2,12 @@ - + - + @@ -17,4 +17,4 @@ - \ No newline at end of file + diff --git a/test/assets/odata_connection.xml b/test/assets/odata_connection.xml new file mode 100644 index 000000000..0c16fcca6 --- /dev/null +++ b/test/assets/odata_connection.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/test/assets/project_update.xml b/test/assets/project_update.xml index eaa884627..f2485c898 100644 --- a/test/assets/project_update.xml +++ b/test/assets/project_update.xml @@ -1,4 +1,6 @@ - + + + diff --git a/test/assets/view_get_id_usage.xml b/test/assets/view_get_id_usage.xml new file mode 100644 index 000000000..a0cdd98db --- /dev/null +++ b/test/assets/view_get_id_usage.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/test/test_datasource.py b/test/test_datasource.py index f258fdc52..624eb93e1 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -52,6 +52,7 @@ def test_get(self) -> None: self.assertEqual("dataengine", all_datasources[0].datasource_type) self.assertEqual("SampleDsDescription", all_datasources[0].description) self.assertEqual("SampleDS", all_datasources[0].content_url) + self.assertEqual(4096, all_datasources[0].size) self.assertEqual("2016-08-11T21:22:40Z", format_datetime(all_datasources[0].created_at)) self.assertEqual("2016-08-11T21:34:17Z", format_datetime(all_datasources[0].updated_at)) self.assertEqual("default", all_datasources[0].project_name) @@ -67,6 +68,7 @@ def test_get(self) -> None: self.assertEqual("dataengine", all_datasources[1].datasource_type) self.assertEqual("description Sample", all_datasources[1].description) self.assertEqual("Sampledatasource", all_datasources[1].content_url) + self.assertEqual(10240, all_datasources[1].size) self.assertEqual("2016-08-04T21:31:55Z", format_datetime(all_datasources[1].created_at)) self.assertEqual("2016-08-04T21:31:55Z", format_datetime(all_datasources[1].updated_at)) self.assertEqual("default", all_datasources[1].project_name) diff --git a/test/test_permissionsrule.py b/test/test_permissionsrule.py new file mode 100644 index 000000000..d7bceb258 --- /dev/null +++ b/test/test_permissionsrule.py @@ -0,0 +1,104 @@ +import unittest + +import tableauserverclient as TSC +from tableauserverclient.models.reference_item import ResourceReference + + +class TestPermissionsRules(unittest.TestCase): + def test_and(self): + grantee = ResourceReference("a", "user") + rule1 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + rule2 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + + composite = rule1 & rule2 + + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportData), TSC.Permission.Mode.Allow) + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.Delete), TSC.Permission.Mode.Deny) + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ViewComments), None) + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportXml), TSC.Permission.Mode.Deny) + + def test_or(self): + grantee = ResourceReference("a", "user") + rule1 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + rule2 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + + composite = rule1 | rule2 + + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportData), TSC.Permission.Mode.Allow) + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.Delete), TSC.Permission.Mode.Allow) + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ViewComments), TSC.Permission.Mode.Allow) + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportXml), TSC.Permission.Mode.Deny) + + def test_eq_false(self): + grantee = ResourceReference("a", "user") + rule1 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + rule2 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + + self.assertNotEqual(rule1, rule2) + + def test_eq_true(self): + grantee = ResourceReference("a", "user") + rule1 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + rule2 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + self.assertEqual(rule1, rule2) diff --git a/test/test_project.py b/test/test_project.py index 33d9c3865..e05785f86 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -79,6 +79,7 @@ def test_update(self) -> None: parent_id="9a8f2265-70f3-4494-96c5-e5949d7a1120", ) single_project._id = "1d0304cd-3796-429f-b815-7258370b9b74" + single_project.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" single_project = self.server.projects.update(single_project) self.assertEqual("1d0304cd-3796-429f-b815-7258370b9b74", single_project.id) @@ -86,6 +87,7 @@ def test_update(self) -> None: self.assertEqual("Project created for testing", single_project.description) self.assertEqual("LockedToProject", single_project.content_permissions) self.assertEqual("9a8f2265-70f3-4494-96c5-e5949d7a1120", single_project.parent_id) + self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", single_project.owner_id) def test_content_permission_locked_to_project_without_nested(self) -> None: with open(SET_CONTENT_PERMISSIONS_XML, "rb") as f: @@ -185,7 +187,7 @@ def test_populate_workbooks(self) -> None: self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/workbooks", text=response_xml ) single_project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") - single_project._owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_project.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" single_project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" self.server.projects.populate_workbook_default_permissions(single_project) diff --git a/test/test_project_model.py b/test/test_project_model.py index 6ddaf8607..ecfe1bd14 100644 --- a/test/test_project_model.py +++ b/test/test_project_model.py @@ -19,8 +19,3 @@ def test_parent_id(self): project = TSC.ProjectItem("proj") project.parent_id = "foo" self.assertEqual(project.parent_id, "foo") - - def test_owner_id(self): - project = TSC.ProjectItem("proj") - with self.assertRaises(NotImplementedError): - project.owner_id = "new_owner" diff --git a/test/test_request_option.py b/test/test_request_option.py index 40dd3345a..e48f8510a 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -331,3 +331,30 @@ def test_filtering_parameters(self) -> None: self.assertIn("value2", query_params["name2$"]) self.assertIn("type", query_params) self.assertIn("tabloid", query_params["type"]) + + def test_queryset_endpoint_pagesize_all(self) -> None: + for page_size in (1, 10, 100, 1000): + with self.subTest(page_size): + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/views?pageSize={page_size}", text=SLICING_QUERYSET_PAGE_1.read_text()) + queryset = self.server.views.all(page_size=page_size) + assert queryset.request_options.pagesize == page_size + _ = list(queryset) + + def test_queryset_endpoint_pagesize_filter(self) -> None: + for page_size in (1, 10, 100, 1000): + with self.subTest(page_size): + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/views?pageSize={page_size}", text=SLICING_QUERYSET_PAGE_1.read_text()) + queryset = self.server.views.filter(page_size=page_size) + assert queryset.request_options.pagesize == page_size + _ = list(queryset) + + def test_queryset_pagesize_filter(self) -> None: + for page_size in (1, 10, 100, 1000): + with self.subTest(page_size): + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/views?pageSize={page_size}", text=SLICING_QUERYSET_PAGE_1.read_text()) + queryset = self.server.views.all().filter(page_size=page_size) + assert queryset.request_options.pagesize == page_size + _ = list(queryset) diff --git a/test/test_site.py b/test/test_site.py index b8469e56c..96b75f9ff 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -1,6 +1,7 @@ import os.path import unittest +import pytest import requests_mock import tableauserverclient as TSC @@ -109,6 +110,8 @@ def test_get_by_name(self) -> None: def test_get_by_name_missing_name(self) -> None: self.assertRaises(ValueError, self.server.sites.get_by_name, "") + @pytest.mark.filterwarnings("ignore:Tiered license level is set") + @pytest.mark.filterwarnings("ignore:FlowsEnabled has been removed") def test_update(self) -> None: with open(UPDATE_XML, "rb") as f: response_xml = f.read().decode("utf-8") @@ -206,6 +209,7 @@ def test_replace_license_tiers_with_user_quota(self) -> None: self.assertEqual(1, test_site.user_quota) self.assertIsNone(test_site.tier_explorer_capacity) + @pytest.mark.filterwarnings("ignore:FlowsEnabled has been removed") def test_create(self) -> None: with open(CREATE_XML, "rb") as f: response_xml = f.read().decode("utf-8") diff --git a/test/test_tableauauth_model.py b/test/test_tableauauth_model.py index e8ae242d9..195bcf0a9 100644 --- a/test/test_tableauauth_model.py +++ b/test/test_tableauauth_model.py @@ -1,5 +1,4 @@ import unittest -import warnings import tableauserverclient as TSC @@ -11,11 +10,3 @@ def setUp(self): def test_username_password_required(self): with self.assertRaises(TypeError): TSC.TableauAuth() - - def test_site_arg_raises_warning(self): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - - tableau_auth = TSC.TableauAuth("user", "password", site="Default") - - self.assertTrue(any(item.category == DeprecationWarning for item in w)) diff --git a/test/test_view.py b/test/test_view.py index 720a0ce64..1c667a4c3 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -13,6 +13,7 @@ GET_XML = os.path.join(TEST_ASSET_DIR, "view_get.xml") GET_XML_ID = os.path.join(TEST_ASSET_DIR, "view_get_id.xml") GET_XML_USAGE = os.path.join(TEST_ASSET_DIR, "view_get_usage.xml") +GET_XML_ID_USAGE = os.path.join(TEST_ASSET_DIR, "view_get_id_usage.xml") POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, "Sample View Image.png") POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf") POPULATE_CSV = os.path.join(TEST_ASSET_DIR, "populate_csv.csv") @@ -81,6 +82,25 @@ def test_get_by_id(self) -> None: self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at)) self.assertEqual("story", view.sheet_type) + def test_get_by_id_usage(self) -> None: + with open(GET_XML_ID_USAGE, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5?includeUsageStatistics=true", text=response_xml) + view = self.server.views.get_by_id("d79634e1-6063-4ec9-95ff-50acbf609ff5", usage=True) + + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", view.id) + self.assertEqual("ENDANGERED SAFARI", view.name) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", view.content_url) + self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", view.workbook_id) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", view.owner_id) + self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", view.project_id) + self.assertEqual(set(["tag1", "tag2"]), view.tags) + self.assertEqual("2002-05-30T09:00:00Z", format_datetime(view.created_at)) + self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at)) + self.assertEqual("story", view.sheet_type) + self.assertEqual(7, view.total_views) + def test_get_by_id_missing_id(self) -> None: self.assertRaises(TSC.MissingRequiredFieldError, self.server.views.get_by_id, None) diff --git a/test/test_workbook.py b/test/test_workbook.py index ac3d44b28..950118dc0 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -7,6 +7,8 @@ from io import BytesIO from pathlib import Path +import pytest + import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.models import UserItem, GroupItem, PermissionsRule @@ -22,6 +24,7 @@ GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_empty.xml") GET_INVALID_DATE_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_invalid_date.xml") GET_XML = os.path.join(TEST_ASSET_DIR, "workbook_get.xml") +ODATA_XML = os.path.join(TEST_ASSET_DIR, "odata_connection.xml") POPULATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_connections.xml") POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf") POPULATE_POWERPOINT = os.path.join(TEST_ASSET_DIR, "populate_powerpoint.pptx") @@ -621,31 +624,7 @@ def test_publish_with_hidden_views_on_workbook(self) -> None: self.assertTrue(re.search(rb"<\/views>", request_body)) self.assertTrue(re.search(rb"<\/views>", request_body)) - # this tests the old method of including workbook views as a parameter for publishing - # should be removed when that functionality is removed - # see https://github.com/tableau/server-client-python/pull/617 - def test_publish_with_hidden_view(self) -> None: - with open(PUBLISH_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) - - new_workbook = TSC.WorkbookItem( - name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - ) - - sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") - publish_mode = self.server.PublishMode.CreateNew - - new_workbook = self.server.workbooks.publish( - new_workbook, sample_workbook, publish_mode, hidden_views=["GDP per capita"] - ) - - request_body = m._adapter.request_history[0]._request.body - # order of attributes in xml is unspecified - self.assertTrue(re.search(rb"<\/views>", request_body)) - self.assertTrue(re.search(rb"<\/views>", request_body)) - + @pytest.mark.filterwarnings("ignore:'as_job' not available") def test_publish_with_query_params(self) -> None: with open(PUBLISH_ASYNC_XML, "rb") as f: response_xml = f.read().decode("utf-8") @@ -775,63 +754,6 @@ def test_publish_multi_connection_flat(self) -> None: self.assertEqual(connection_results[1].get("serverAddress", None), "pgsql.test.com") self.assertEqual(connection_results[1].find("connectionCredentials").get("password", None), "secret") # type: ignore[union-attr] - def test_publish_single_connection(self) -> None: - new_workbook = TSC.WorkbookItem( - name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - ) - connection_creds = TSC.ConnectionCredentials("test", "secret", True) - - response = RequestFactory.Workbook._generate_xml(new_workbook, connection_credentials=connection_creds) - # Can't use ConnectionItem parser due to xml namespace problems - credentials = fromstring(response).findall(".//connectionCredentials") - self.assertEqual(len(credentials), 1) - self.assertEqual(credentials[0].get("name", None), "test") - self.assertEqual(credentials[0].get("password", None), "secret") - self.assertEqual(credentials[0].get("embed", None), "true") - - def test_publish_single_connection_username_none(self) -> None: - new_workbook = TSC.WorkbookItem( - name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - ) - connection_creds = TSC.ConnectionCredentials(None, "secret", True) - - self.assertRaises( - ValueError, - RequestFactory.Workbook._generate_xml, - new_workbook, - connection_credentials=connection_creds, - ) - - def test_publish_single_connection_username_empty(self) -> None: - new_workbook = TSC.WorkbookItem( - name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - ) - connection_creds = TSC.ConnectionCredentials("", "secret", True) - - response = RequestFactory.Workbook._generate_xml(new_workbook, connection_credentials=connection_creds) - # Can't use ConnectionItem parser due to xml namespace problems - credentials = fromstring(response).findall(".//connectionCredentials") - self.assertEqual(len(credentials), 1) - self.assertEqual(credentials[0].get("name", None), "") - self.assertEqual(credentials[0].get("password", None), "secret") - self.assertEqual(credentials[0].get("embed", None), "true") - - def test_credentials_and_multi_connect_raises_exception(self) -> None: - new_workbook = TSC.WorkbookItem( - name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - ) - - connection_creds = TSC.ConnectionCredentials("test", "secret", True) - - connection1 = TSC.ConnectionItem() - connection1.server_address = "mysql.test.com" - connection1.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) - - with self.assertRaises(RuntimeError): - response = RequestFactory.Workbook._generate_xml( - new_workbook, connection_credentials=connection_creds, connections=[connection1] - ) - def test_synchronous_publish_timeout_error(self) -> None: with requests_mock.mock() as m: m.register_uri("POST", self.baseurl, status_code=504) @@ -944,3 +866,31 @@ def test_bad_download_response(self) -> None: ) file_path = self.server.workbooks.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td) self.assertTrue(os.path.exists(file_path)) + + def test_odata_connection(self) -> None: + self.baseurl = self.server.workbooks.baseurl + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + connection = TSC.ConnectionItem() + url = "https://odata.website.com/TestODataEndpoint" + connection.server_address = url + connection._connection_type = "odata" + connection._id = "17376070-64d1-4d17-acb4-a56e4b5b1768" + + creds = TSC.ConnectionCredentials("", "", True) + connection.connection_credentials = creds + with open(ODATA_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{workbook.id}/connections/{connection.id}", text=response_xml) + self.server.workbooks.update_connection(workbook, connection) + + history = m.request_history + + request = history[0] + xml = fromstring(request.body) + xml_connection = xml.find(".//connection") + + assert xml_connection is not None + self.assertEqual(xml_connection.get("serverAddress"), url)