diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index d858c3389..2549773c0 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -30,7 +30,7 @@ jobs: # https://github.com/marketplace/actions/pytest-coverage-comment - name: Generate coverage report - run: pytest --junitxml=pytest.xml --cov=tableauserverclient tests/ | tee pytest-coverage.txt + run: pytest --junitxml=pytest.xml --cov=tableauserverclient test/ | tee pytest-coverage.txt - name: Comment on pull request with coverage uses: MishaKav/pytest-coverage-comment@main diff --git a/README.md b/README.md index 71bf9b023..ab6a66fae 100644 --- a/README.md +++ b/README.md @@ -19,4 +19,4 @@ For more information on installing and using TSC, see the documentation: ## License -[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python?ref=badge_large) \ No newline at end of file +[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python?ref=badge_large) diff --git a/pyproject.toml b/pyproject.toml index 717ca7cde..8ec6df4d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,8 @@ 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-subtests", "requests-mock>=1.0,<2.0"] +test = ["argparse", "black==23.7", "mock", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", + "requests-mock>=1.0,<2.0"] [tool.black] line-length = 120 diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 03e484372..c5c3c1922 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,6 +1,47 @@ from ._version import get_versions from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE -from .models import * +from .models import ( + BackgroundJobItem, + ColumnItem, + ConnectionCredentials, + ConnectionItem, + CustomViewItem, + DQWItem, + DailyInterval, + DataAlertItem, + DatabaseItem, + 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, +) from .server import ( CSVRequestOptions, ExcelRequestOptions, diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index b4a52f753..03d692583 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -31,7 +31,7 @@ from .site_item import SiteItem from .subscription_item import SubscriptionItem from .table_item import TableItem -from .tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth +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 diff --git a/tableauserverclient/models/column_item.py b/tableauserverclient/models/column_item.py index dbf200d21..df936e315 100644 --- a/tableauserverclient/models/column_item.py +++ b/tableauserverclient/models/column_item.py @@ -9,6 +9,9 @@ def __init__(self, name, description=None): self.description = description self.name = name + def __repr__(self): + return f"<{self.__class__.__name__} {self._id} {self.name} {self.description}>" + @property def id(self): return self._id diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py index db65de0ad..d61bbb751 100644 --- a/tableauserverclient/models/connection_credentials.py +++ b/tableauserverclient/models/connection_credentials.py @@ -15,6 +15,13 @@ def __init__(self, name, password, embed=True, oauth=False): self.embed = embed self.oauth = oauth + def __repr__(self): + if self.password: + print = "redacted" + else: + print = "None" + return f"<{self.__class__.__name__} name={self.name} password={print} embed={self.embed} oauth={self.oauth} >" + @property def embed(self): return self._embed diff --git a/tableauserverclient/models/data_acceleration_report_item.py b/tableauserverclient/models/data_acceleration_report_item.py index 3c1d6ed40..7424e6b95 100644 --- a/tableauserverclient/models/data_acceleration_report_item.py +++ b/tableauserverclient/models/data_acceleration_report_item.py @@ -46,6 +46,9 @@ def avg_non_accelerated_plt(self): def __init__(self, comparison_records): self._comparison_records = comparison_records + def __repr__(self): + return f"<(deprecated)DataAccelerationReportItem site={self.site} sheet={sheet_uri}>" + @property def comparison_records(self): return self._comparison_records diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 96c3ae675..6c8f7eb01 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -26,11 +26,9 @@ def __init__(self, name=None, domain_name=None) -> None: self.name: Optional[str] = name self.domain_name: Optional[str] = domain_name - def __str__(self): + def __repr__(self): return "{}({!r})".format(self.__class__.__name__, self.__dict__) - __repr__ = __str__ - @property def domain_name(self) -> Optional[str]: return self._domain_name @@ -48,7 +46,6 @@ def name(self) -> Optional[str]: return self._name @name.setter - @property_not_empty def name(self, value: str) -> None: self._name = value diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 25b6d09d7..02b57591b 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -31,6 +31,9 @@ def __init__(self, start_time, end_time, interval_value): self.end_time = end_time self.interval = interval_value + def __repr__(self): + return f"<{self.__class__.__name__} start={self.start_time} end={self.end_time} interval={self.interval}>" + @property def _frequency(self): return IntervalItem.Frequency.Hourly @@ -86,6 +89,9 @@ def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values + def __repr__(self): + return f"<{self.__class__.__name__} start={self.start_time} interval={self.interval}>" + @property def _frequency(self): return IntervalItem.Frequency.Daily @@ -114,6 +120,9 @@ def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values + def __repr__(self): + return f"<{self.__class__.__name__} start={self.start_time} interval={self.interval}>" + @property def _frequency(self): return IntervalItem.Frequency.Weekly @@ -148,6 +157,9 @@ def __init__(self, start_time, interval_value): self.start_time = start_time self.interval = str(interval_value) + def __repr__(self): + return f"<{self.__class__.__name__} start={self.start_time} interval={self.interval}>" + @property def _frequency(self): return IntervalItem.Frequency.Monthly diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 5a2636246..61e7a8d18 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -117,12 +117,15 @@ def flow_run(self, value): def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at - def __repr__(self): + def __str__(self): return ( "".format(**self.__dict__) ) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @classmethod def from_response(cls, xml, ns) -> List["JobItem"]: parsed_response = fromstring(xml) @@ -202,6 +205,12 @@ def __init__( self._title = title self._subtitle = subtitle + def __str__(self): + return f"<{self.__class__.name} {self._id} {self._type}>" + + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def id(self) -> str: return self._id diff --git a/tableauserverclient/models/metric_item.py b/tableauserverclient/models/metric_item.py index e390d2c4d..d8ba8e825 100644 --- a/tableauserverclient/models/metric_item.py +++ b/tableauserverclient/models/metric_item.py @@ -115,9 +115,12 @@ def view_id(self, value: Optional[str]) -> None: def _set_permissions(self, permissions): self._permissions = permissions - def __repr__(self): + def __str__(self): return "".format(**vars(self)) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @classmethod def from_response( cls, diff --git a/tableauserverclient/models/pagination_item.py b/tableauserverclient/models/pagination_item.py index 2cb89dc5e..8cebd1c86 100644 --- a/tableauserverclient/models/pagination_item.py +++ b/tableauserverclient/models/pagination_item.py @@ -7,6 +7,9 @@ def __init__(self): self._page_size = None self._total_available = None + def __repr__(self): + return f"" + @property def page_number(self) -> int: return self._page_number diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 1602b077f..d2b2227db 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -1,4 +1,3 @@ -import logging import xml.etree.ElementTree as ET from typing import Dict, List, Optional @@ -17,6 +16,9 @@ class Mode: Allow = "Allow" Deny = "Deny" + def __repr__(self): + return "" + class Capability: AddComment = "AddComment" ChangeHierarchy = "ChangeHierarchy" @@ -39,17 +41,18 @@ class Capability: CreateRefreshMetrics = "CreateRefreshMetrics" SaveAs = "SaveAs" + def __repr__(self): + return "" + class PermissionsRule(object): def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> None: self.grantee = grantee self.capabilities = capabilities - def __str__(self): + def __repr__(self): return "".format(self.grantee, self.capabilities) - __repr__ = __str__ - @classmethod def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: parsed_response = fromstring(resp) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index edfd0fe70..dc0eca948 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -48,9 +48,12 @@ def __init__(self, name: str, priority: int, schedule_type: str, execution_order self.priority: int = priority self.schedule_type: str = schedule_type - def __repr__(self): + def __str__(self): return ''.format(**vars(self)) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def created_at(self) -> Optional[datetime]: return self._created_at diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index b180665dd..57fc51af9 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -12,7 +12,7 @@ def __init__(self, product_version, build_number, rest_api_version): self._build_number = build_number self._rest_api_version = rest_api_version - def __str__(self): + def __repr__(self): return ( "ServerInfoItem: [product version: " + self._product_version diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 813e812af..b651e5773 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -39,6 +39,9 @@ def __str__(self): + ">" ) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + class AdminMode: ContentAndUsers: str = "ContentAndUsers" ContentOnly: str = "ContentOnly" diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index 7fbaa32d2..f9df8a8f3 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -19,6 +19,12 @@ def __init__(self, name, description=None): self._columns = None self._data_quality_warnings = None + def __str__(self): + return f"<{self.__class__.__name__} {self._id} {self._name} >" + + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def permissions(self): if self._permissions is None: diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 30639d09b..9aca206d7 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -43,7 +43,11 @@ def credentials(self): return {"name": self.username, "password": self.password} def __repr__(self): - return "".format(self.username, "") + if self.user_id_to_impersonate: + uid = f", user_id_to_impersonate=f{self.user_id_to_impersonate}" + else: + uid = "" + return f"" @property def site(self): @@ -56,6 +60,7 @@ def site(self, value): 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): if personal_access_token is None or token_name is None: @@ -72,13 +77,19 @@ def credentials(self): } def __repr__(self): - return "(site={})".format( - self.token_name, self.personal_access_token[:2] + "...", self.site_id + if self.user_id_to_impersonate: + uid = f", user_id_to_impersonate=f{self.user_id_to_impersonate}" + else: + uid = "" + return ( + f"" ) +# A standard JWT generated specifically for Tableau class JWTAuth(Credentials): - def __init__(self, jwt=None, site_id=None, user_id_to_impersonate=None): + def __init__(self, jwt: str, site_id=None, user_id_to_impersonate=None): if jwt is None: raise TabError("Must provide a JWT token when using JWT authentication") super().__init__(site_id, user_id_to_impersonate) @@ -93,4 +104,4 @@ def __repr__(self): uid = f", user_id_to_impersonate=f{self.user_id_to_impersonate}" else: uid = "" - return f"<{self.__class__.__qualname__}(jwt={self.jwt[:5]}..., site_id={self.site_id}{uid})>" + return f"<{self.__class__.__qualname__} jwt={self.jwt[:5]}... (site={self.site_id}{uid})>" diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index a12f4b557..fe659575a 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -67,10 +67,13 @@ def __init__( return None - def __repr__(self) -> str: + def __str__(self) -> str: str_site_role = self.site_role or "None" return "".format(self.id, self.name, str_site_role) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def auth_setting(self) -> Optional[str]: return self._auth_setting diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index ef1fb0e52..90cff490b 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -32,11 +32,14 @@ def __init__(self) -> None: self._permissions: Optional[Callable[[], List[PermissionsRule]]] = None self.tags: Set[str] = set() - def __repr__(self): + def __str__(self): return "".format( self._id, self.name, self.content_url, self.project_id ) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + def _set_preview_image(self, preview_image): self._preview_image = preview_image diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 16e05498b..86a9a2f18 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -53,11 +53,14 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, return None - def __repr__(self): + def __str__(self): return "".format( self._id, self.name, self.content_url, self.project_id ) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def connections(self) -> List[ConnectionItem]: if self._connections is None: diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 2025de5fb..0b6bac0c9 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -66,9 +66,14 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id)) return Auth.contextmgr(self.sign_out) + # We use the same request that username/password login uses for all auth types. + # The distinct methods are mostly useful for explicitly showing api version support for each auth type @api(version="3.6") def sign_in_with_personal_access_token(self, auth_req: "Credentials") -> contextmgr: - # We use the same request that username/password login uses. + return self.sign_in(auth_req) + + @api(version="3.17") + def sign_in_with_json_web_token(self, auth_req: "Credentials") -> contextmgr: return self.sign_in(auth_req) @api(version="2.0") diff --git a/test/models/_models.py b/test/models/_models.py index a1630da9c..59011c6c3 100644 --- a/test/models/_models.py +++ b/test/models/_models.py @@ -1,61 +1,58 @@ from tableauserverclient import * -# mmm. why aren't these available in the tsc namespace? +# TODO why aren't these available in the tsc namespace? Probably a bug. from tableauserverclient.models import ( DataAccelerationReportItem, - FavoriteItem, Credentials, ServerInfoItem, Resource, TableauItem, - plural_type, ) def get_defined_models(): - # not clever: copied from tsc/models/__init__.py + # nothing clever here: list was manually copied from tsc/models/__init__.py return [ - ColumnItem, - ConnectionCredentials, + BackgroundJobItem, ConnectionItem, DataAccelerationReportItem, DataAlertItem, - DatabaseItem, DatasourceItem, - DQWItem, - UnpopulatedPropertyError, - FavoriteItem, FlowItem, - FlowRunItem, GroupItem, - IntervalItem, - DailyInterval, - WeeklyInterval, - MonthlyInterval, - HourlyInterval, JobItem, - BackgroundJobItem, MetricItem, - PaginationItem, PermissionsRule, - Permission, ProjectItem, RevisionItem, ScheduleItem, - ServerInfoItem, - SiteItem, SubscriptionItem, - TableItem, Credentials, + JWTAuth, TableauAuth, PersonalAccessTokenAuth, - Resource, - TableauItem, - plural_type, - Target, + ServerInfoItem, + SiteItem, TaskItem, UserItem, ViewItem, WebhookItem, WorkbookItem, + PaginationItem, + Permission.Mode, + Permission.Capability, + DailyInterval, + WeeklyInterval, + MonthlyInterval, + HourlyInterval, + TableItem, + Target, + ] + + +def get_unimplemented_models(): + return [ + FavoriteItem, # no repr because there is no state + Resource, # list of type names + TableauItem, # should be an interface ] diff --git a/test/models/test_repr.py b/test/models/test_repr.py index d21e4bc4a..92d11978f 100644 --- a/test/models/test_repr.py +++ b/test/models/test_repr.py @@ -1,40 +1,51 @@ -import pytest +import inspect from unittest import TestCase import _models # type: ignore # did not set types for this +import tableauserverclient as TSC +from typing import Any -# ensure that all models have a __repr__ method implemented -class TestAllModels(TestCase): - """ - ColumnItem wrapper_descriptor - ConnectionCredentials wrapper_descriptor - DataAccelerationReportItem wrapper_descriptor - DatabaseItem wrapper_descriptor - DQWItem wrapper_descriptor - UnpopulatedPropertyError wrapper_descriptor - FavoriteItem wrapper_descriptor - FlowRunItem wrapper_descriptor - IntervalItem wrapper_descriptor - DailyInterval wrapper_descriptor - WeeklyInterval wrapper_descriptor - MonthlyInterval wrapper_descriptor - HourlyInterval wrapper_descriptor - BackgroundJobItem wrapper_descriptor - PaginationItem wrapper_descriptor - Permission wrapper_descriptor - ServerInfoItem wrapper_descriptor - SiteItem wrapper_descriptor - TableItem wrapper_descriptor - Resource wrapper_descriptor - """ +# ensure that all models that don't need parameters can be instantiated +# todo.... +def instantiate_class(name: str, obj: Any): + # Get the constructor (init) of the class + constructor = getattr(obj, "__init__", None) + if constructor: + # Get the parameters of the constructor (excluding 'self') + parameters = inspect.signature(constructor).parameters.values() + required_parameters = [ + param for param in parameters if param.default == inspect.Parameter.empty and param.name != "self" + ] + if required_parameters: + print(f"Class '{name}' requires the following parameters for instantiation:") + for param in required_parameters: + print(f"- {param.name}") + else: + print(f"Class '{name}' does not require any parameters for instantiation.") + # Instantiate the class + instance = obj() + print(f"Instantiated: {name} -> {instance}") + else: + print(f"Class '{name}' does not have a constructor (__init__ method).") + +class TestAllModels(TestCase): # not all models have __repr__ yet: see above list - @pytest.mark.xfail() def test_repr_is_implemented(self): m = _models.get_defined_models() for model in m: with self.subTest(model.__name__, model=model): print(model.__name__, type(model.__repr__).__name__) self.assertEqual(type(model.__repr__).__name__, "function") + + # 2 - Iterate through the objects in the module + def test_by_reflection(self): + for class_name, obj in inspect.getmembers(TSC, is_concrete): + with self.subTest(class_name, obj=obj): + instantiate_class(class_name, obj) + + +def is_concrete(obj: Any): + return inspect.isclass(obj) and not inspect.isabstract(obj) diff --git a/test/test_group_model.py b/test/test_group_model.py index 6b79dc18a..659a3611f 100644 --- a/test/test_group_model.py +++ b/test/test_group_model.py @@ -4,16 +4,6 @@ class GroupModelTests(unittest.TestCase): - def test_invalid_name(self): - self.assertRaises(ValueError, TSC.GroupItem, None) - self.assertRaises(ValueError, TSC.GroupItem, "") - group = TSC.GroupItem("grp") - with self.assertRaises(ValueError): - group.name = None - - with self.assertRaises(ValueError): - group.name = "" - def test_invalid_minimum_site_role(self): group = TSC.GroupItem("grp") with self.assertRaises(ValueError):