diff --git a/pyproject.toml b/pyproject.toml index c3cb67eda..67faefbe1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,13 +43,13 @@ target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] check_untyped_defs = false disable_error_code = [ 'misc', - # tableauserverclient\server\endpoint\datasources_endpoint.py:48: error: Cannot assign multiple types to name "FilePath" without an explicit "Type[...]" annotation [misc] 'annotation-unchecked' # can be removed when check_untyped_defs = true ] files = ["tableauserverclient", "test", "samples"] show_error_codes = true ignore_missing_imports = true # defusedxml library has no types no_implicit_reexport = true +implicit_optional = true [tool.pytest.ini_options] testpaths = ["test"] diff --git a/samples/export.py b/samples/export.py index 815ec8b51..e33710468 100644 --- a/samples/export.py +++ b/samples/export.py @@ -37,7 +37,9 @@ def main(): "--csv", dest="type", action="store_const", const=("populate_csv", "CSVRequestOptions", "csv", "csv") ) # other options shown in explore_workbooks: workbook.download, workbook.preview_image - + parser.add_argument( + "--language", help="Text such as 'Average' will appear in this language. Use values like fr, de, es, en" + ) parser.add_argument("--workbook", action="store_true") parser.add_argument("--file", "-f", help="filename to store the exported data") @@ -74,16 +76,18 @@ def main(): populate = getattr(server.workbooks, populate_func_name) option_factory = getattr(TSC, option_factory_name) + options: TSC.PDFRequestOptions = option_factory() if args.filter: - options = option_factory().vf(*args.filter.split(":")) - else: - options = None + options = options.vf(*args.filter.split(":")) + + if args.language: + options.language = args.language if args.file: filename = args.file else: - filename = f"out.{extension}" + filename = f"out-{options.language}.{extension}" populate(item, options) with open(filename, "wb") as f: diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 1299c33bc..e0a7abb64 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -32,11 +32,13 @@ PermissionsRule, PersonalAccessTokenAuth, ProjectItem, + Resource, RevisionItem, ScheduleItem, SiteItem, ServerInfoItem, SubscriptionItem, + TableauItem, TableItem, TableauAuth, Target, @@ -66,66 +68,68 @@ ) __all__ = [ - "get_versions", - "DEFAULT_NAMESPACE", "BackgroundJobItem", "BackgroundJobItem", "ColumnItem", "ConnectionCredentials", "ConnectionItem", + "CSVRequestOptions", "CustomViewItem", - "DQWItem", "DailyInterval", "DataAlertItem", "DatabaseItem", "DataFreshnessPolicyItem", "DatasourceItem", + "DEFAULT_NAMESPACE", + "DQWItem", + "ExcelRequestOptions", "FailedSignInError", "FavoriteItem", + "FileuploadItem", + "Filter", "FlowItem", "FlowRunItem", - "FileuploadItem", + "get_versions", "GroupItem", "GroupSetItem", "HourlyInterval", + "ImageRequestOptions", "IntervalItem", "JobItem", "JWTAuth", + "LinkedTaskFlowRunItem", + "LinkedTaskItem", + "LinkedTaskStepItem", "MetricItem", + "MissingRequiredFieldError", "MonthlyInterval", + "NotSignedInError", + "Pager", "PaginationItem", + "PDFRequestOptions", "Permission", "PermissionsRule", "PersonalAccessTokenAuth", "ProjectItem", + "RequestOptions", + "Resource", "RevisionItem", "ScheduleItem", - "SiteItem", + "Server", "ServerInfoItem", + "ServerResponseError", + "SiteItem", + "Sort", "SubscriptionItem", - "TableItem", "TableauAuth", + "TableauItem", + "TableItem", "Target", "TaskItem", "UserItem", "ViewItem", + "VirtualConnectionItem", "WebhookItem", "WeeklyInterval", "WorkbookItem", - "CSVRequestOptions", - "ExcelRequestOptions", - "ImageRequestOptions", - "PDFRequestOptions", - "RequestOptions", - "MissingRequiredFieldError", - "NotSignedInError", - "ServerResponseError", - "Filter", - "Pager", - "Server", - "Sort", - "LinkedTaskItem", - "LinkedTaskStepItem", - "LinkedTaskFlowRunItem", - "VirtualConnectionItem", ] diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index a3ad0c498..0d47abfcc 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,4 +1,5 @@ import sys +from typing import Optional from typing_extensions import Self @@ -26,11 +27,48 @@ def apply_query_params(self, url): except NotImplementedError: raise - def get_query_params(self): - raise NotImplementedError() + +# If it wasn't a breaking change, I'd rename it to QueryOptions +""" +This class manages options can be used when querying content on the server +""" class RequestOptions(RequestOptionsBase): + def __init__(self, pagenumber=1, pagesize=None): + self.pagenumber = pagenumber + self.pagesize = pagesize or config.PAGE_SIZE + self.sort = set() + self.filter = set() + # This is private until we expand all of our parsers to handle the extra fields + self._all_fields = False + + def get_query_params(self) -> dict: + params = {} + if self.sort and len(self.sort) > 0: + sort_options = (str(sort_item) for sort_item in self.sort) + ordered_sort_options = sorted(sort_options) + params["sort"] = ",".join(ordered_sort_options) + if len(self.filter) > 0: + filter_options = (str(filter_item) for filter_item in self.filter) + ordered_filter_options = sorted(filter_options) + params["filter"] = ",".join(ordered_filter_options) + if self._all_fields: + params["fields"] = "_all_" + if self.pagenumber: + params["pageNumber"] = self.pagenumber + if self.pagesize: + params["pageSize"] = self.pagesize + return params + + def page_size(self, page_size): + self.pagesize = page_size + return self + + def page_number(self, page_number): + self.pagenumber = page_number + return self + class Operator: Equals = "eq" GreaterThan = "gt" @@ -41,6 +79,7 @@ class Operator: Has = "has" CaseInsensitiveEquals = "cieq" + # These are fields in the REST API class Field: Args = "args" AuthenticationType = "authenticationType" @@ -117,51 +156,43 @@ class Direction: Desc = "desc" Asc = "asc" - def __init__(self, pagenumber=1, pagesize=None): - self.pagenumber = pagenumber - self.pagesize = pagesize or config.PAGE_SIZE - self.sort = set() - self.filter = set() - # This is private until we expand all of our parsers to handle the extra fields - self._all_fields = False - - def page_size(self, page_size): - self.pagesize = page_size - return self +""" +These options can be used by methods that are fetching data exported from a specific content item +""" - def page_number(self, page_number): - self.pagenumber = page_number - return self - - def get_query_params(self): - params = {} - if self.pagenumber: - params["pageNumber"] = self.pagenumber - if self.pagesize: - params["pageSize"] = self.pagesize - if len(self.sort) > 0: - sort_options = (str(sort_item) for sort_item in self.sort) - ordered_sort_options = sorted(sort_options) - params["sort"] = ",".join(ordered_sort_options) - if len(self.filter) > 0: - filter_options = (str(filter_item) for filter_item in self.filter) - ordered_filter_options = sorted(filter_options) - params["filter"] = ",".join(ordered_filter_options) - if self._all_fields: - params["fields"] = "_all_" - return params +class _DataExportOptions(RequestOptionsBase): + def __init__(self, maxage: int = -1): + super().__init__() + self.view_filters: list[tuple[str, str]] = [] + self.view_parameters: list[tuple[str, str]] = [] + self.max_age: Optional[int] = maxage + """ + This setting will affect the contents of the workbook as they are exported. + Valid language values are tableau-supported languages like de, es, en + If no locale is specified, the default locale for that language will be used + """ + self.language: Optional[str] = None -class _FilterOptionsBase(RequestOptionsBase): - """Provide a basic implementation of adding view filters to the url""" + @property + def max_age(self) -> int: + return self._max_age - def __init__(self): - self.view_filters = [] - self.view_parameters = [] + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value): + self._max_age = value def get_query_params(self): - raise NotImplementedError() + params = {} + if self.max_age != -1: + params["maxAge"] = self.max_age + if self.language: + params["language"] = self.language + + self._append_view_filters(params) + return params def vf(self, name: str, value: str) -> Self: """Apply a filter based on a column within the view. @@ -182,82 +213,33 @@ def _append_view_filters(self, params) -> None: params[name] = value -class CSVRequestOptions(_FilterOptionsBase): - def __init__(self, maxage=-1): - super().__init__() - self.max_age = maxage - - @property - def max_age(self): - return self._max_age +class CSVRequestOptions(_DataExportOptions): + extension = "csv" - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value): - self._max_age = value - - def get_query_params(self): - params = {} - if self.max_age != -1: - params["maxAge"] = self.max_age - - self._append_view_filters(params) - return params - - -class ExcelRequestOptions(_FilterOptionsBase): - def __init__(self, maxage: int = -1) -> None: - super().__init__() - self.max_age = maxage - - @property - def max_age(self) -> int: - return self._max_age - - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value: int) -> None: - self._max_age = value - def get_query_params(self): - params = {} - if self.max_age != -1: - params["maxAge"] = self.max_age +class ExcelRequestOptions(_DataExportOptions): + extension = "xlsx" - self._append_view_filters(params) - return params +class ImageRequestOptions(_DataExportOptions): + extension = "png" -class ImageRequestOptions(_FilterOptionsBase): # if 'high' isn't specified, the REST API endpoint returns an image with standard resolution class Resolution: High = "high" def __init__(self, imageresolution=None, maxage=-1): - super().__init__() + super().__init__(maxage=maxage) self.image_resolution = imageresolution - self.max_age = maxage - - @property - def max_age(self): - return self._max_age - - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value): - self._max_age = value def get_query_params(self): - params = {} + params = super().get_query_params() if self.image_resolution: params["resolution"] = self.image_resolution - if self.max_age != -1: - params["maxAge"] = self.max_age - self._append_view_filters(params) return params -class PDFRequestOptions(_FilterOptionsBase): +class PDFRequestOptions(_DataExportOptions): class PageType: A3 = "a3" A4 = "a4" @@ -279,22 +261,12 @@ class Orientation: Landscape = "landscape" def __init__(self, page_type=None, orientation=None, maxage=-1, viz_height=None, viz_width=None): - super().__init__() + super().__init__(maxage=maxage) self.page_type = page_type self.orientation = orientation - self.max_age = maxage self.viz_height = viz_height self.viz_width = viz_width - @property - def max_age(self): - return self._max_age - - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value): - self._max_age = value - @property def viz_height(self): return self._viz_height @@ -313,17 +285,14 @@ def viz_width(self): def viz_width(self, value): self._viz_width = value - def get_query_params(self): - params = {} + def get_query_params(self) -> dict: + params = super().get_query_params() if self.page_type: params["type"] = self.page_type if self.orientation: params["orientation"] = self.orientation - if self.max_age != -1: - params["maxAge"] = self.max_age - # XOR. Either both are None or both are not None. if (self.viz_height is None) ^ (self.viz_width is None): raise ValueError("viz_height and viz_width must be specified together") @@ -334,6 +303,4 @@ def get_query_params(self): if self.viz_width is not None: params["vizWidth"] = self.viz_width - self._append_view_filters(params) - return params diff --git a/test/test_request_option.py b/test/test_request_option.py index 9ca9779ad..7405189a3 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -358,3 +358,13 @@ def test_queryset_pagesize_filter(self) -> None: queryset = self.server.views.all().filter(page_size=page_size) assert queryset.request_options.pagesize == page_size _ = list(queryset) + + def test_language_export(self) -> None: + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = self.baseurl + "/views/456/data" + opts = TSC.PDFRequestOptions() + opts.language = "en-US" + + resp = self.server.users.get_request(url, request_object=opts) + self.assertTrue(re.search("language=en-us", resp.request.query))