diff --git a/contributing.md b/contributing.md index 90fbdc4f0..41c339cb6 100644 --- a/contributing.md +++ b/contributing.md @@ -66,18 +66,22 @@ pytest pip install . ``` +### Debugging Tools +See what your outgoing requests look like: https://requestbin.net/ (unaffiliated link not under our control) + + ### Before Committing Our CI runs include a Python lint run, so you should run this locally and fix complaints before committing as this will fail your checkin. ```shell # this will run the formatter without making changes -black --line-length 120 tableauserverclient test samples --check +black . --check # this will format the directory and code for you -black --line-length 120 tableauserverclient test samples +black . # this will run type checking pip install mypy -mypy --show-error-codes --disable-error-code misc --disable-error-code import tableauserverclient test +mypy tableauserverclient test samples ``` diff --git a/samples/create_group.py b/samples/create_group.py index 50d84a187..d5cf712db 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -46,7 +46,7 @@ def main(): logging.basicConfig(level=logging_level) tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=True) + server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False}) with server.auth.sign_in(tableau_auth): # this code shows 3 different error codes that mean "resource is already in collection" # 409009: group already exists on server diff --git a/samples/initialize_server.py b/samples/initialize_server.py index 586011120..21b243013 100644 --- a/samples/initialize_server.py +++ b/samples/initialize_server.py @@ -56,7 +56,7 @@ def main(): # Create the site if it doesn't exist if existing_site is None: - print("Site not found: {0} Creating it...").format(args.site_id) + print("Site not found: {0} Creating it...".format(args.site_id)) new_site = TSC.SiteItem( name=args.site_id, content_url=args.site_id.replace(" ", ""), @@ -64,7 +64,7 @@ def main(): ) server.sites.create(new_site) else: - print("Site {0} exists. Moving on...").format(args.site_id) + print("Site {0} exists. Moving on...".format(args.site_id)) ################################################################################ # Step 3: Sign-in to our target site @@ -87,7 +87,7 @@ def main(): # Create our project if it doesn't exist if project is None: - print("Project not found: {0} Creating it...").format(args.project) + print("Project not found: {0} Creating it...".format(args.project)) new_project = TSC.ProjectItem(name=args.project) project = server_upload.projects.create(new_project) diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 394184120..7c1e6d705 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -39,6 +39,7 @@ ) from .server import ( CSVRequestOptions, + ExcelRequestOptions, ImageRequestOptions, PDFRequestOptions, RequestOptions, diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index d957f5e14..18f0ecae2 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -93,10 +93,6 @@ def description(self, value: str) -> None: def project_name(self) -> Optional[str]: return self._project_name - @property - def flow_type(self): # What is this? It doesn't seem to get set anywhere. - return self._flow_type - @property def updated_at(self) -> Optional["datetime.datetime"]: return self._updated_at diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 1c1e9db4d..74b167e9d 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -69,7 +69,7 @@ def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: mode = capability_xml.get("mode") if name is None or mode is None: - logger.error("Capability was not valid: ", capability_xml) + logger.error("Capability was not valid: {}".format(capability_xml)) raise UnpopulatedPropertyError() else: capability_dict[name] = mode diff --git a/tableauserverclient/models/revision_item.py b/tableauserverclient/models/revision_item.py index 024d45edd..a49be88a7 100644 --- a/tableauserverclient/models/revision_item.py +++ b/tableauserverclient/models/revision_item.py @@ -53,8 +53,9 @@ def user_name(self) -> Optional[str]: def __repr__(self): return ( - "" - ).format(**self.__dict__) + "".format(**self.__dict__) + ) @classmethod def from_response(cls, resp: bytes, ns, resource_item) -> List["RevisionItem"]: diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index d0ac5d292..350ae3a0d 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -1,3 +1,6 @@ +import warnings +import xml + from defusedxml.ElementTree import fromstring @@ -32,7 +35,11 @@ def rest_api_version(self): @classmethod def from_response(cls, resp, ns): - parsed_response = fromstring(resp) + try: + parsed_response = fromstring(resp) + except xml.etree.ElementTree.ParseError as error: + warnings.warn("Unexpected response for ServerInfo: {}".format(resp)) + return cls("Unknown", "Unknown", "Unknown") product_version_tag = parsed_response.find(".//t:productVersion", namespaces=ns) rest_api_version_tag = parsed_response.find(".//t:restApiVersion", namespaces=ns) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 3deda03e2..8c9e8fe8e 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -1,7 +1,6 @@ import warnings import xml.etree.ElementTree as ET -from distutils.version import Version from defusedxml.ElementTree import fromstring from .property_decorators import ( property_is_enum, diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index f373a84ab..6ad0fda5a 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -9,7 +9,7 @@ def credentials(self): +"This method returns values to set as an attribute on the credentials element of the request" def __repr__(self): - display = "All Credentials types must have a debug display that does not print secrets" + return "All Credentials types must have a debug display that does not print secrets" def deprecate_site_attribute(): diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 25abb3c9a..84d118a2e 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -2,6 +2,7 @@ from .request_factory import RequestFactory from .request_options import ( CSVRequestOptions, + ExcelRequestOptions, ImageRequestOptions, PDFRequestOptions, RequestOptions, diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 1fab7ac4b..aa9d73f18 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -116,7 +116,7 @@ def update_table_default_permissions(self, item): @api(version="3.5") def delete_table_default_permissions(self, item): - self._default_permissions.delete_default_permissions(item, Resource.Table) + self._default_permissions.delete_default_permission(item, Resource.Table) @api(version="3.5") def populate_dqw(self, item): diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 378c84746..3cdc49322 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,6 +1,6 @@ import requests import logging -from distutils.version import LooseVersion as Version +from packaging.version import Version from functools import wraps from xml.etree.ElementTree import ParseError from typing import Any, Callable, Dict, Optional, TYPE_CHECKING @@ -11,9 +11,12 @@ NonXMLResponseError, EndpointUnavailableError, ) -from .. import endpoint from ..query import QuerySet from ... import helpers +from ..._version import get_versions + +__TSC_VERSION__ = get_versions()["version"] +del get_versions logger = logging.getLogger("tableau.endpoint") @@ -22,34 +25,25 @@ XML_CONTENT_TYPE = "text/xml" JSON_CONTENT_TYPE = "application/json" +USERAGENT_HEADER = "User-Agent" + if TYPE_CHECKING: from ..server import Server from requests import Response -_version_header: Optional[str] = None - - class Endpoint(object): def __init__(self, parent_srv: "Server"): - global _version_header self.parent_srv = parent_srv @staticmethod def _make_common_headers(auth_token, content_type): - global _version_header - - if not _version_header: - from ..server import __TSC_VERSION__ - - _version_header = __TSC_VERSION__ - headers = {} if auth_token is not None: headers["x-tableau-auth"] = auth_token if content_type is not None: headers["content-type"] = content_type - headers["User-Agent"] = "Tableau Server Client/{}".format(_version_header) + headers["User-Agent"] = "Tableau Server Client/{}".format(__TSC_VERSION__) return headers def _make_request( @@ -62,9 +56,9 @@ def _make_request( parameters: Optional[Dict[str, Any]] = None, ) -> "Response": parameters = parameters or {} - parameters.update(self.parent_srv.http_options) if "headers" not in parameters: parameters["headers"] = {} + parameters.update(self.parent_srv.http_options) parameters["headers"].update(Endpoint._make_common_headers(auth_token, content_type)) if content is not None: @@ -89,14 +83,12 @@ def _check_status(self, server_response, url: str = None): if server_response.status_code >= 500: raise InternalServerError(server_response, url) elif server_response.status_code not in Success_codes: - # todo: is an error reliably of content-type application/xml? try: raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace, url) except ParseError: - # This will happen if we get a non-success HTTP code that - # doesn't return an xml error object (like metadata endpoints or 503 pages) - # we convert this to a better exception and pass through the raw - # response body + # This will happen if we get a non-success HTTP code that doesn't return an xml error object + # e.g metadata endpoints, 503 pages, totally different servers + # we convert this to a better exception and pass through the raw response body raise NonXMLResponseError(server_response.content) except Exception: # anything else re-raise here @@ -194,7 +186,7 @@ def api(version): def _decorator(func): @wraps(func) def wrapper(self, *args, **kwargs): - self.parent_srv.assert_at_least_version(version, "endpoint") + self.parent_srv.assert_at_least_version(version, self.__class__.__name__) return func(self, *args, **kwargs) return wrapper diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index 2036d8d5e..943aabee6 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -12,6 +12,19 @@ class ServerInfo(Endpoint): + def __init__(self, server): + self.parent_srv = server + self._info = None + + @property + def serverInfo(self): + if not self._info: + self.get() + return self._info + + def __repr__(self): + return "".format(self.serverInfo) + @property def baseurl(self): return "{0}/serverInfo".format(self.parent_srv.baseurl) @@ -23,10 +36,10 @@ def get(self): server_response = self.get_unauthenticated_request(self.baseurl) except ServerResponseError as e: if e.code == "404003": - raise ServerInfoEndpointNotFoundError + raise ServerInfoEndpointNotFoundError(e) if e.code == "404001": - raise EndpointUnavailableError + raise EndpointUnavailableError(e) raise e - server_info = ServerInfoItem.from_response(server_response.content, self.parent_srv.namespace) - return server_info + self._info = ServerInfoItem.from_response(server_response.content, self.parent_srv.namespace) + return self._info diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index c82f4a6e2..ebe11dac7 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,3 +1,5 @@ +import warnings + import requests import urllib3 @@ -37,11 +39,6 @@ from ..namespace import Namespace -from .._version import get_versions - -__TSC_VERSION__ = get_versions()["version"] -del get_versions - _PRODUCT_TO_REST_VERSION = { "10.0": "2.3", "9.3": "2.2", @@ -51,7 +48,6 @@ } minimum_supported_server_version = "2.3" default_server_version = "2.3" -client_version_header = "X-TableauServerClient-Version" class Server(object): @@ -60,15 +56,14 @@ class PublishMode: Overwrite = "Overwrite" CreateNew = "CreateNew" - def __init__(self, server_address, use_server_version=False, http_options=None): - self._server_address = server_address + def __init__(self, server_address, use_server_version=False, http_options=None, session_factory=None): self._auth_token = None self._site_id = None self._user_id = None - self._session = requests.Session() - self._http_options = dict() - self.version = default_server_version + self._server_address = server_address + self._session_factory = session_factory or requests.session + self.auth = Auth(self) self.views = Views(self) self.users = Users(self) @@ -95,32 +90,48 @@ def __init__(self, server_address, use_server_version=False, http_options=None): self.flow_runs = FlowRuns(self) self.metrics = Metrics(self) - # must set this before calling use_server_version, because that's a server call + self._session = self._session_factory() + self._http_options = dict() # must set this before making a server call if http_options: self.add_http_options(http_options) - self.add_http_version_header() + self.validate_server_connection() + + self.version = default_server_version if use_server_version: - self.use_server_version() + self.use_server_version() # this makes a server call - def add_http_options(self, options_dict): - self._http_options.update(options_dict) - if options_dict.get("verify") == False: - urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + def validate_server_connection(self): + try: + self._session.prepare_request(requests.Request("GET", url=self._server_address, params=self._http_options)) + except Exception as req_ex: + warnings.warn("Invalid server initialization\n {}".format(req_ex.__str__()), UserWarning) + print("==================") - def add_http_version_header(self): - if not self._http_options[client_version_header]: - self._http_options.update({client_version_header: __TSC_VERSION__}) + def __repr__(self): + return " [Connection: {}, {}]".format(self.baseurl, self.server_info.serverInfo) + + def add_http_options(self, options_dict: dict): + try: + self._http_options.update(options_dict) + if "verify" in options_dict.keys() and self._http_options.get("verify") is False: + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + # would be nice if you could turn them back on + except BaseException as be: + print(be) + # expected errors on invalid input: + # 'set' object has no attribute 'keys', 'list' object has no attribute 'keys' + # TypeError: cannot convert dictionary update sequence element #0 to a sequence (input is a tuple) + raise ValueError("Invalid http options given: {}".format(options_dict)) def clear_http_options(self): self._http_options = dict() - self.add_http_version_header() def _clear_auth(self): self._site_id = None self._user_id = None self._auth_token = None - self._session = requests.Session() + self._session = self._session_factory() def _set_auth(self, site_id, user_id, auth_token): self._site_id = site_id @@ -141,9 +152,10 @@ def _determine_highest_version(self): version = self.server_info.get().rest_api_version except ServerInfoEndpointNotFoundError: version = self._get_legacy_version() + except BaseException: + version = self._get_legacy_version() - finally: - self.version = old_version + self.version = old_version return version diff --git a/test/http/test_http_requests.py b/test/http/test_http_requests.py new file mode 100644 index 000000000..a5f4f4669 --- /dev/null +++ b/test/http/test_http_requests.py @@ -0,0 +1,79 @@ +import tableauserverclient as TSC +import unittest +import requests + +from requests_mock import adapter, mock +from requests.exceptions import MissingSchema + + +class ServerTests(unittest.TestCase): + def test_init_server_model_empty_throws(self): + with self.assertRaises(TypeError): + server = TSC.Server() + + def test_init_server_model_bad_server_name_complains(self): + # by default, it will just set the version to 2.3 + server = TSC.Server("fake-url") + + def test_init_server_model_valid_server_name_works(self): + # by default, it will just set the version to 2.3 + server = TSC.Server("http://fake-url") + + def test_init_server_model_valid_https_server_name_works(self): + # by default, it will just set the version to 2.3 + server = TSC.Server("https://fake-url") + + def test_init_server_model_bad_server_name_not_version_check(self): + # by default, it will just set the version to 2.3 + server = TSC.Server("fake-url", use_server_version=False) + + def test_init_server_model_bad_server_name_do_version_check(self): + with self.assertRaises(MissingSchema): + server = TSC.Server("fake-url", use_server_version=True) + + def test_init_server_model_bad_server_name_not_version_check_random_options(self): + # by default, it will just set the version to 2.3 + server = TSC.Server("fake-url", use_server_version=False, http_options={"foo": 1}) + + def test_init_server_model_bad_server_name_not_version_check_real_options(self): + server = TSC.Server("fake-url", use_server_version=False, http_options={"verify": False}) + + def test_http_options_skip_ssl_works(self): + http_options = {"verify": False} + server = TSC.Server("http://fake-url") + server.add_http_options(http_options) + + def test_http_options_multiple_options_works(self): + http_options = {"verify": False, "birdname": "Parrot"} + server = TSC.Server("http://fake-url") + server.add_http_options(http_options) + + # ValueError: dictionary update sequence element #0 has length 1; 2 is required + def test_http_options_multiple_dicts_fails(self): + http_options_1 = {"verify": False} + http_options_2 = {"birdname": "Parrot"} + server = TSC.Server("http://fake-url") + with self.assertRaises(ValueError): + server.add_http_options([http_options_1, http_options_2]) + + # TypeError: cannot convert dictionary update sequence element #0 to a sequence + def test_http_options_not_sequence_fails(self): + server = TSC.Server("http://fake-url") + with self.assertRaises(ValueError): + server.add_http_options({1, 2, 3}) + + +class SessionTests(unittest.TestCase): + test_header = {"x-test": "true"} + + @staticmethod + def session_factory(): + session = requests.session() + session.headers.update(SessionTests.test_header) + return session + + def test_session_factory_adds_headers(self): + test_request_bin = "http://capture-this-with-mock.com" + with mock() as m: + m.get(url="http://capture-this-with-mock.com/api/2.4/serverInfo", request_headers=SessionTests.test_header) + server = TSC.Server(test_request_bin, use_server_version=True, session_factory=SessionTests.session_factory) diff --git a/test/test_view.py b/test/test_view.py index 3562650d1..f5d3db47b 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -294,7 +294,7 @@ def test_populate_excel(self) -> None: m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/crosstab/excel?maxAge=1", content=response) single_view = TSC.ViewItem() single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - request_option = TSC.CSVRequestOptions(maxage=1) + request_option = TSC.ExcelRequestOptions(maxage=1) self.server.views.populate_excel(single_view, request_option) excel_file = b"".join(single_view.excel)