From c4ebcef7155949064c8e951d4d0a2bbd6719ac86 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 4 Aug 2022 17:01:02 -0700 Subject: [PATCH 01/10] improved debugging: show url that failed Include the url of the request that got an error in the response. Makes it much easier to debug. --- .../server/endpoint/auth_endpoint.py | 4 +-- .../server/endpoint/endpoint.py | 10 +++---- .../server/endpoint/exceptions.py | 26 ++++++++++++------- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 11e89975a..6baf399ed 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -30,7 +30,7 @@ def sign_in(self, auth_req): signin_req = RequestFactory.Auth.signin_req(auth_req) server_response = self.parent_srv.session.post(url, data=signin_req, **self.parent_srv.http_options) self.parent_srv._namespace.detect(server_response.content) - self._check_status(server_response) + self._check_status(server_response, url) parsed_response = fromstring(server_response.content) site_id = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("id", None) user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) @@ -66,7 +66,7 @@ def switch_site(self, site_item): else: raise e self.parent_srv._namespace.detect(server_response.content) - self._check_status(server_response) + self._check_status(server_response, url) parsed_response = fromstring(server_response.content) site_id = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("id", None) user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 0acc978d2..39ed233d5 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -63,7 +63,7 @@ def _make_request( logger.debug("request content: {}".format(helpers.strings.redact_xml(content[:1000]))) server_response = method(url, **parameters) - self._check_status(server_response) + self._check_status(server_response, url) loggable_response = self.log_response_safely(server_response) logger.debug("Server response from {0}:\n\t{1}".format(url, loggable_response)) @@ -73,13 +73,13 @@ def _make_request( return server_response - def _check_status(self, server_response): + def _check_status(self, server_response, url: str = None): if server_response.status_code >= 500: - raise InternalServerError(server_response) + 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) + 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) @@ -126,7 +126,7 @@ def get_request(self, url, request_object=None, parameters=None): ) def delete_request(self, url): - # We don't return anything for a delete + # We don't return anything for a delete request self._make_request(self.parent_srv.session.delete, url, auth_token=self.parent_srv.auth_token) def put_request(self, url, xml_request=None, content_type=XML_CONTENT_TYPE, parameters=None): diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 34de00dd0..8e8e9a341 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -2,34 +2,40 @@ class ServerResponseError(Exception): - def __init__(self, code, summary, detail): + def __init__(self, code, summary, detail, url = None): self.code = code self.summary = summary self.detail = detail + self.url = url super(ServerResponseError, self).__init__(str(self)) def __str__(self): return "\n\n\t{0}: {1}\n\t\t{2}".format(self.code, self.summary, self.detail) @classmethod - def from_response(cls, resp, ns): + def from_response(cls, resp, ns, url=None): # Check elements exist before .text parsed_response = fromstring(resp) - error_response = cls( - parsed_response.find("t:error", namespaces=ns).get("code", ""), - parsed_response.find(".//t:summary", namespaces=ns).text, - parsed_response.find(".//t:detail", namespaces=ns).text, - ) + try: + error_response = cls( + parsed_response.find("t:error", namespaces=ns).get("code", ""), + parsed_response.find(".//t:summary", namespaces=ns).text, + parsed_response.find(".//t:detail", namespaces=ns).text, + url + ) + except Exception as e: + raise NonXMLResponseError(resp) return error_response -class InternalServerError(Exception): - def __init__(self, server_response): +class InternalServerError(ServerResponseError): + def __init__(self, server_response, url=None): self.code = server_response.status_code self.content = server_response.content + self.url = url def __str__(self): - return "\n\nError status code: {0}\n{1}".format(self.code, self.content) + return "\n\nError status code: {0}\n{1}\n{2}".format(self.code, self.content, self.url) class MissingRequiredFieldError(Exception): From 669a78a3035c9893009eb5c32299185b3588de4b Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 4 Aug 2022 17:15:45 -0700 Subject: [PATCH 02/10] test fix --- tableauserverclient/server/endpoint/server_info_endpoint.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index 5c9461d1c..2036d8d5e 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -26,6 +26,7 @@ def get(self): raise ServerInfoEndpointNotFoundError if e.code == "404001": raise EndpointUnavailableError + raise e server_info = ServerInfoItem.from_response(server_response.content, self.parent_srv.namespace) return server_info From 0aeb704f47d954b372f81b5e24093f93931d330f Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 4 Aug 2022 17:16:34 -0700 Subject: [PATCH 03/10] Add user import logic --- tableauserverclient/models/user_item.py | 166 +++++++++++++++++- .../server/endpoint/users_endpoint.py | 49 +++++- test/assets/Data/user_details.csv | 1 + test/assets/Data/usernames.csv | 7 + test/test_user.py | 23 +++ test/test_user_model.py | 113 ++++++++++++ 6 files changed, 348 insertions(+), 11 deletions(-) create mode 100644 test/assets/Data/user_details.csv create mode 100644 test/assets/Data/usernames.csv diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index f60e72951..c3d7178fc 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -1,7 +1,8 @@ -from datetime import datetime +import io +import logging import xml.etree.ElementTree as ET from datetime import datetime -from typing import Dict, List, Optional, TYPE_CHECKING +from enum import IntEnum from defusedxml.ElementTree import fromstring @@ -9,14 +10,10 @@ from .property_decorators import ( property_is_enum, property_not_empty, - property_not_nullable, ) from .reference_item import ResourceReference from ..datetime_helpers import parse_datetime -if TYPE_CHECKING: - from ..server.pager import Pager - from typing import Dict, List, Optional, TYPE_CHECKING if TYPE_CHECKING: @@ -111,7 +108,6 @@ def site_role(self) -> Optional[str]: return self._site_role @site_role.setter - @property_not_nullable @property_is_enum(Roles) def site_role(self, value): self._site_role = value @@ -260,4 +256,158 @@ def _parse_element(user_xml, ns): ) def __repr__(self) -> str: - return "".format(self.id, self.name, self.site_role) + str_site_role = self.site_role or "None" + return "".format(self.id, self.name, str_site_role) + + # valid values for each field + CHOICES: List[List[str]] = [ + [], + [], + [], + ["creator", "explorer", "viewer", "unlicensed"], # license + ["system", "site", "none", "no"], # admin + ["yes", "true", "1", "no", "false", "0"], # publisher + [], + [Auth.SAML, Auth.OpenID, Auth.ServerDefault], # auth + ] + + class CSVImportFileItem(object): + + # CSV import file format + # username, password, display_name, license, admin_level, publishing, email, auth type + class Column(IntEnum): + USERNAME = 0 + PASS = 1 + DISPLAY_NAME = 2 + LICENSE = 3 # aka site role + ADMIN = 4 + PUBLISHER = 5 + EMAIL = 6 + AUTH = 7 + + MAX = 7 + + @staticmethod + def _create_from_csv_line(line: str): + if line is None or line is False or line == "\n" or line == "": + return None + line = line.strip().lower() + values: List[str] = list(map(str.strip, line.split(","))) + user = UserItem(values[UserItem.CSVImportFileItem.Column.USERNAME]) + if len(values) > 1: + if len(values) > UserItem.CSVImportFileItem.Column.MAX: + raise ValueError("Too many attributes for user import") + while len(values) <= UserItem.CSVImportFileItem.Column.MAX: + values.append("") + site_role = UserItem.CSVImportFileItem.evaluate_site_role( + values[UserItem.CSVImportFileItem.Column.LICENSE], + values[UserItem.CSVImportFileItem.Column.ADMIN], + values[UserItem.CSVImportFileItem.Column.PUBLISHER], + ) + + user._set_values(None, + values[UserItem.CSVImportFileItem.Column.USERNAME], + site_role, + None, + None, + values[UserItem.CSVImportFileItem.Column.DISPLAY_NAME], + values[UserItem.CSVImportFileItem.Column.EMAIL], + values[UserItem.CSVImportFileItem.Column.AUTH], + None) + return user + + @staticmethod + def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> List[int]: + num_errors = 0 + num_valid_lines = 0 + csv_file.seek(0) # set to start of file in case it has been read earlier + line: str = csv_file.readline() + while line and line != "": + try: + # do not print passwords + logger.info("Reading user {}".format(line[:4])) + UserItem.CSVImportFileItem._validate_imported_attributes_or_throw(line, logger) + num_valid_lines += 1 + except Exception as exc: + logger.info("Error parsing {}: {}".format(line[:4], exc)) + num_errors += 1 + line = csv_file.readline() + return [num_valid_lines, num_errors] + + # this might belong in the main User class + # valid: username, domain/username, username@domain, domain/username@email + @staticmethod + def _validate_username_or_throw(username) -> None: + if username is None or username == "" or username.strip(" ") == "": + raise AttributeError("Username cannot be empty") + if username.find(" ") >= 0: + raise AttributeError("Username cannot contain spaces") + at_symbol = username.find("@") + if at_symbol >= 0: + username = username[:at_symbol] + "X" + username[at_symbol + 1:] + if username.find("@") >= 0: + raise AttributeError("Username cannot repeat '@'") + + @staticmethod + def _validate_imported_attributes_or_throw(incoming, logger) -> None: + line = list(map(str.strip, incoming.split(","))) + if len(line) > UserItem.CSVImportFileItem.Column.MAX: + raise AttributeError("Too many attributes in line") + username = line[UserItem.CSVImportFileItem.Column.USERNAME.value] + logger.debug("> details - {}".format(username)) + UserItem.CSVImportFileItem._validate_username_or_throw(username) + for i in range(1, len(line)): + logger.debug("column {}: {}".format(UserItem.CSVImportFileItem.Column(i).name, + line[i])) + UserItem.CSVImportFileItem._validate_item( + line[i], + UserItem.CHOICES[i], + UserItem.CSVImportFileItem.Column(i)) + + @staticmethod + def _validate_item(item: str, possible_values: List[str], column_type) -> None: + if item is None or item == "": + # value can be empty for any column except user, which is checked elsewhere + return + if item in possible_values or possible_values == []: + return + raise AttributeError("Invalid value {} for {}").format(item, column_type) + + # https://help.tableau.com/current/server/en-us/csvguidelines.htm#settings_and_site_roles + @staticmethod + def evaluate_site_role(license_level, admin_level, publisher): + if not license_level or not admin_level or not publisher: + return "Unlicensed" + # ignore case everywhere + license_level = license_level.lower() + admin_level = admin_level.lower() + publisher = publisher.lower() + # don't need to check publisher for system/site admin + if admin_level == "system": + site_role = "SiteAdministrator" + elif admin_level == "site": + if license_level == "creator": + site_role = "SiteAdministratorCreator" + elif license_level == "explorer": + site_role = "SiteAdministratorExplorer" + else: + site_role = "SiteAdministratorExplorer" + else: # if it wasn't 'system' or 'site' then we can treat it as 'none' + if publisher == "yes": + if license_level == "creator": + site_role = "Creator" + elif license_level == "explorer": + site_role = "ExplorerCanPublish" + else: + site_role = "Unlicensed" # is this the expected outcome? + else: # publisher == 'no': + if license_level == "explorer" or license_level == "creator": + site_role = "Explorer" + elif license_level == "viewer": + site_role = "Viewer" + else: # if license_level == 'unlicensed' + site_role = "Unlicensed" + if site_role is None: + site_role = "Unlicensed" + return site_role + diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 738364cd7..e0142f16d 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,19 +1,23 @@ import copy import logging -from typing import List, Optional, Tuple +import os +from typing import List, Optional, Tuple, Union from .endpoint import QuerysetEndpoint, api -from .exceptions import MissingRequiredFieldError +from .exceptions import MissingRequiredFieldError, ServerResponseError from .. import ( RequestFactory, RequestOptions, UserItem, WorkbookItem, PaginationItem, - GroupItem, + GroupItem ) from ..pager import Pager +# duplicate defined in workbooks_endpoint +FilePath = Union[str, os.PathLike] + logger = logging.getLogger("tableau.endpoint.users") @@ -78,12 +82,51 @@ def remove(self, user_id: str, map_assets_to: Optional[str] = None) -> None: @api(version="2.0") def add(self, user_item: UserItem) -> UserItem: url = self.baseurl + logger.info("Add user {}".format(user_item.name)) add_req = RequestFactory.User.add_req(user_item) server_response = self.post_request(url, add_req) + logger.info(server_response) new_user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() logger.info("Added new user (ID: {0})".format(new_user.id)) return new_user + # Add new users to site. This does not actually perform a bulk action, it's syntactic sugar + @api(version="2.0") + def add_all(self, users: List[UserItem]): + created = [] + failed = [] + for user in users: + try: + result = self.add(user) + created.append(result) + except Exception as e: + failed.append(user) + return created, failed + + # helping the user by parsing a file they could have used to add users through the UI + # line format: Username [required], password, display name, license, admin, publish + @api(version="2.0") + def create_from_file(self, filepath: FilePath = None) -> (List[UserItem], List[UserItem]): + created = [] + failed = [] + if not filepath.find("csv"): + raise ValueError("Only csv files are accepted") + + with open(filepath) as csv_file: + csv_file.seek(0) # set to start of file in case it has been read earlier + line: str = csv_file.readline() + while line and line != "": + user: UserItem = UserItem.CSVImportFileItem._create_from_csv_line(line) + try: + print(user) + result = self.add(user) + created.append(result) + except ServerResponseError as serverError: + print("failed") + failed.append((user, serverError)) + line = csv_file.readline() + return created, failed + # Get workbooks for user @api(version="2.0") def populate_workbooks(self, user_item: UserItem, req_options: RequestOptions = None) -> None: diff --git a/test/assets/Data/user_details.csv b/test/assets/Data/user_details.csv new file mode 100644 index 000000000..15b975942 --- /dev/null +++ b/test/assets/Data/user_details.csv @@ -0,0 +1 @@ +username, pword, , yes, email diff --git a/test/assets/Data/usernames.csv b/test/assets/Data/usernames.csv new file mode 100644 index 000000000..0350c0dd6 --- /dev/null +++ b/test/assets/Data/usernames.csv @@ -0,0 +1,7 @@ +valid, +valid@email.com, +domain/valid, +domain/valid@tmail.com, +va!@#$%^&*()lid, +in@v@lid, +in valid, diff --git a/test/test_user.py b/test/test_user.py index b8fe32388..f829f24c5 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,5 +1,8 @@ +import io import os import unittest +from typing import List +from unittest.mock import MagicMock import requests_mock @@ -17,6 +20,8 @@ GET_FAVORITES_XML = os.path.join(TEST_ASSET_DIR, "favorites_get.xml") POPULATE_GROUPS_XML = os.path.join(TEST_ASSET_DIR, "user_populate_groups.xml") +USERNAMES = os.path.join(TEST_ASSET_DIR, "Data", "usernames.csv") +USERS = os.path.join(TEST_ASSET_DIR, "Data", "user_details.csv") class UserTests(unittest.TestCase): def setUp(self) -> None: @@ -212,3 +217,21 @@ def test_populate_groups(self) -> None: self.assertEqual("86a66d40-f289-472a-83d0-927b0f954dc8", group_list[2].id) self.assertEqual("TableauExample", group_list[2].name) self.assertEqual("local", group_list[2].domain_name) + + def test_get_usernames_from_file(self): + with open(ADD_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post(self.server.users.baseurl, text=response_xml) + user_list, failures = self.server.users.create_from_file(USERNAMES) + assert user_list[0].name == "Cassie", user_list + assert failures == [], failures + + def test_get_users_from_file(self): + with open(ADD_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post(self.server.users.baseurl, text=response_xml) + users, failures = self.server.users.create_from_file(USERS) + assert users[0].name == "Cassie", users + assert failures == [] diff --git a/test/test_user_model.py b/test/test_user_model.py index ba70b1c7c..8affebed9 100644 --- a/test/test_user_model.py +++ b/test/test_user_model.py @@ -1,4 +1,10 @@ +import logging import unittest +from unittest.mock import * +from typing import List +import io + +import pytest import tableauserverclient as TSC @@ -23,3 +29,110 @@ def test_invalid_site_role(self): user = TSC.UserItem("me", TSC.UserItem.Roles.Publisher) with self.assertRaises(ValueError): user.site_role = "Hello" + + +class UserDataTest(unittest.TestCase): + + logger = logging.getLogger("UserDataTest") + + role_inputs = [ + ["creator", "system", "yes", "SiteAdministrator"], + ["None", "system", "no", "SiteAdministrator"], + ["explorer", "SysTEm", "no", "SiteAdministrator"], + ["creator", "site", "yes", "SiteAdministratorCreator"], + ["explorer", "site", "yes", "SiteAdministratorExplorer"], + ["creator", "SITE", "no", "SiteAdministratorCreator"], + ["creator", "none", "yes", "Creator"], + ["explorer", "none", "yes", "ExplorerCanPublish"], + ["viewer", "None", "no", "Viewer"], + ["explorer", "no", "yes", "ExplorerCanPublish"], + ["EXPLORER", "noNO", "yes", "ExplorerCanPublish"], + ["explorer", "no", "no", "Explorer"], + ["unlicensed", "none", "no", "Unlicensed"], + ["Chef", "none", "yes", "Unlicensed"], + ["yes", "yes", "yes", "Unlicensed"], + ] + + valid_import_content = [ + "username, pword, fname, creator, site, yes, email", + "username, pword, fname, explorer, none, no, email", + "", + "u", + "p", + ] + + valid_username_content = ["jfitzgerald@tableau.com"] + + usernames = [ + "valid", + "valid@email.com", + "domain/valid", + "domain/valid@tmail.com", + "va!@#$%^&*()lid", + "in@v@lid", + "in valid", + "", + ] + + def test_validate_usernames(self): + TSC.UserItem.CSVImportFileItem._validate_username_or_throw(UserDataTest.usernames[0]) + TSC.UserItem.CSVImportFileItem._validate_username_or_throw(UserDataTest.usernames[1]) + TSC.UserItem.CSVImportFileItem._validate_username_or_throw(UserDataTest.usernames[2]) + TSC.UserItem.CSVImportFileItem._validate_username_or_throw(UserDataTest.usernames[3]) + TSC.UserItem.CSVImportFileItem._validate_username_or_throw(UserDataTest.usernames[4]) + with self.assertRaises(AttributeError): + TSC.UserItem.CSVImportFileItem._validate_username_or_throw(UserDataTest.usernames[5]) + with self.assertRaises(AttributeError): + TSC.UserItem.CSVImportFileItem._validate_username_or_throw(UserDataTest.usernames[6]) + + def test_evaluate_role(self): + for line in UserDataTest.role_inputs: + actual = TSC.UserItem.CSVImportFileItem.evaluate_site_role(line[0], line[1], line[2]) + assert actual == line[3], line + [actual] + + def test_get_user_detail_empty_line(self): + test_line = "" + test_user = TSC.UserItem.CSVImportFileItem._create_from_csv_line(test_line) + assert test_user is None + + def test_get_user_detail_standard(self): + test_line = "username, pword, fname, license, admin, pub, email" + test_user: TSC.UserItem = TSC.UserItem.CSVImportFileItem._create_from_csv_line(test_line) + assert test_user.name == "username", test_user.name + assert test_user.fullname == "fname", test_user.fullname + assert test_user.site_role == "Unlicensed", test_user.site_role + assert test_user.email == "email", test_user.email + + def test_get_user_details_only_username(self): + test_line = "username" + test_user: TSC.UserItem = TSC.UserItem.CSVImportFileItem._create_from_csv_line(test_line) + + def test_populate_user_details_only_some(self): + values = "username, , , creator, admin" + user = TSC.UserItem.CSVImportFileItem._create_from_csv_line(values) + assert user.name == "username" + + def test_validate_user_detail_standard(self): + test_line = "username, pword, fname, creator, site, 1, email" + TSC.UserItem.CSVImportFileItem._validate_imported_attributes_or_throw(test_line, UserDataTest.logger) + TSC.UserItem.CSVImportFileItem._create_from_csv_line(test_line) + + # for file handling + def _mock_file_content(self, content: List[str]) -> io.TextIOWrapper: + # the empty string represents EOF + # the tests run through the file twice, first to validate then to fetch + mock = MagicMock(io.TextIOWrapper) + content.append("") # EOF + mock.readline.side_effect = content + mock.name = "file-mock" + return mock + + def test_validate_import_file(self): + test_data = self._mock_file_content(UserDataTest.valid_import_content) + valid, invalid = TSC.UserItem.CSVImportFileItem.validate_file_for_import(test_data, UserDataTest.logger) + assert valid == 2, "Expected two lines to be parsed, got {}".format(valid) + assert invalid == 0, "Expected no failures, got {}".format(invalid) + def test_validate_usernames_file(self): + test_data = self._mock_file_content(UserDataTest.usernames) + valid, invalid = TSC.UserItem.CSVImportFileItem.validate_file_for_import(test_data, UserDataTest.logger) + assert valid == 5, "Exactly 5 of the lines were valid, counted {}".format(valid + invalid) From 92e620cb025cf6f940a6ee08d452fdc5199428e4 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 4 Aug 2022 17:17:18 -0700 Subject: [PATCH 04/10] Add user import example in samples --- samples/create_group.py | 47 +++++++++++++++++++++++++++++++++++++--- samples/online_users.csv | 2 ++ 2 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 samples/online_users.csv diff --git a/samples/create_group.py b/samples/create_group.py index 3875ffea5..b58039dbd 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -8,10 +8,13 @@ import argparse import logging +import os from datetime import time +from typing import List import tableauserverclient as TSC +from tableauserverclient import ServerResponseError def main(): @@ -35,7 +38,7 @@ def main(): ) # Options specific to this sample # This sample has no additional options, yet. If you add some, please add them here - + parser.add_argument("--file", help="csv file containing user info", required=False) args = parser.parse_args() # Set logging level based on user input, or error by default @@ -45,10 +48,48 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) 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 + # 409107: user is already on site + # 409011: user is already in group + group = TSC.GroupItem("test") - group = server.groups.create(group) - print(group) + try: + group = server.groups.create(group) + except TSC.server.endpoint.exceptions.ServerResponseError as rError: + if rError.code == "409009": + print("Group already exists") + group = server.groups.filter(name=group.name)[0] + else: + raise rError + server.groups.populate_users(group) + for user in group.users: + print(user.name) + + if args.file: + filepath = os.path.abspath(args.file) + print("Add users to site from file {}:".format(filepath)) + added: List[TSC.UserItem] + failed: List[TSC.UserItem, TSC.ServerResponseError] + added, failed = server.users.create_from_file(filepath) + for user, error in failed: + print(user, error.code) + if error.code == "409017": + user = server.users.filter(name=user.name)[0] + added.append(user) + print("Adding users to group:{}".format(added)) + for user in added: + print("Adding user {}".format(user)) + try: + server.groups.add_user(group, user.id) + except ServerResponseError as serverError: + if serverError.code == "409011": + print("user {} is already a member of group {}".format(user.name, group.name)) + else: + raise rError + for user in group.users: + print(user.name) if __name__ == "__main__": main() diff --git a/samples/online_users.csv b/samples/online_users.csv new file mode 100644 index 000000000..bf4843679 --- /dev/null +++ b/samples/online_users.csv @@ -0,0 +1,2 @@ +ayoung@tableau.com, , , "Creator", None, Yes +ahsiao@tableau.com, , , "Explorer", None, No From ed47de16ec09a8be463a9d52d265822734f2b83f Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 4 Aug 2022 17:17:27 -0700 Subject: [PATCH 05/10] comments and print display --- tableauserverclient/models/group_item.py | 5 +++++ tableauserverclient/server/query.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 6fcf18544..c84223b5a 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -27,6 +27,11 @@ def __init__(self, name=None, domain_name=None) -> None: self.name: Optional[str] = name self.domain_name: Optional[str] = domain_name + def __str__(self): + return "{}({!r})".format(self.__class__.__name__, self.__dict__) + + __repr__ = __str__ + @property def domain_name(self) -> Optional[str]: return self._domain_name diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 6a262ae77..fa0b338c8 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -9,6 +9,11 @@ def to_camel_case(word: str) -> str: return word.split("_")[0] + "".join(x.capitalize() or "_" for x in word.split("_")[1:]) +""" +This interface allows more fluent queries against Tableau Server +e.g server.users.get(name="user@domain.com") +see pagination_sample +""" class QuerySet: def __init__(self, model): self.model = model From 335d0f44e2ab5186f3ba351577d34961352fac7f Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 4 Aug 2022 17:23:06 -0700 Subject: [PATCH 06/10] black and mypy fixes --- samples/create_group.py | 1 + setup.py | 51 ++-- tableauserverclient/models/user_item.py | 33 ++- .../server/endpoint/exceptions.py | 4 +- .../server/endpoint/users_endpoint.py | 11 +- tableauserverclient/server/query.py | 2 + test/test_user.py | 1 + test/test_user_model.py | 1 + versioneer.py | 226 ++++++++++-------- 9 files changed, 176 insertions(+), 154 deletions(-) diff --git a/samples/create_group.py b/samples/create_group.py index b58039dbd..50d84a187 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -91,5 +91,6 @@ def main(): for user in group.users: print(user.name) + if __name__ == "__main__": main() diff --git a/setup.py b/setup.py index 24d35250c..f9b072ec6 100644 --- a/setup.py +++ b/setup.py @@ -7,43 +7,44 @@ from distutils.core import setup from os import path + this_directory = path.abspath(path.dirname(__file__)) -with open(path.join(this_directory, 'README.md'), encoding='utf-8') as f: +with open(path.join(this_directory, "README.md"), encoding="utf-8") as f: long_description = f.read() # Only install pytest and runner when test command is run # This makes work easier for offline installs or low bandwidth machines -needs_pytest = {'pytest', 'test', 'ptr'}.intersection(sys.argv) -pytest_runner = ['pytest-runner'] if needs_pytest else [] -test_requirements = ['black', 'mock', 'pytest', 'requests-mock>=1.0,<2.0', 'mypy>=0.920'] +needs_pytest = {"pytest", "test", "ptr"}.intersection(sys.argv) +pytest_runner = ["pytest-runner"] if needs_pytest else [] +test_requirements = ["black", "mock", "pytest", "requests-mock>=1.0,<2.0", "mypy>=0.920"] setup( - name='tableauserverclient', + name="tableauserverclient", version=versioneer.get_version(), cmdclass=versioneer.get_cmdclass(), - author='Tableau', - author_email='github@tableau.com', - url='https://github.com/tableau/server-client-python', - package_data={'tableauserverclient':['py.typed']}, - packages=['tableauserverclient', - 'tableauserverclient.helpers', - 'tableauserverclient.models', - 'tableauserverclient.server', - 'tableauserverclient.server.endpoint'], - license='MIT', - description='A Python module for working with the Tableau Server REST API.', + author="Tableau", + author_email="github@tableau.com", + url="https://github.com/tableau/server-client-python", + package_data={"tableauserverclient": ["py.typed"]}, + packages=[ + "tableauserverclient", + "tableauserverclient.helpers", + "tableauserverclient.models", + "tableauserverclient.server", + "tableauserverclient.server.endpoint", + ], + license="MIT", + description="A Python module for working with the Tableau Server REST API.", long_description=long_description, - long_description_content_type='text/markdown', - test_suite='test', + long_description_content_type="text/markdown", + test_suite="test", setup_requires=pytest_runner, install_requires=[ - 'defusedxml>=0.7.1', - 'requests>=2.11,<3.0', + "defusedxml>=0.7.1", + "requests>=2.11,<3.0", ], - python_requires='>3.7.0', + python_requires=">3.7.0", tests_require=test_requirements, - extras_require={ - 'test': test_requirements - }, - zip_safe=False + extras_require={"test": test_requirements}, + zip_safe=False, ) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index c3d7178fc..59b1ab8e6 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -305,15 +305,17 @@ def _create_from_csv_line(line: str): values[UserItem.CSVImportFileItem.Column.PUBLISHER], ) - user._set_values(None, - values[UserItem.CSVImportFileItem.Column.USERNAME], - site_role, - None, - None, - values[UserItem.CSVImportFileItem.Column.DISPLAY_NAME], - values[UserItem.CSVImportFileItem.Column.EMAIL], - values[UserItem.CSVImportFileItem.Column.AUTH], - None) + user._set_values( + None, + values[UserItem.CSVImportFileItem.Column.USERNAME], + site_role, + None, + None, + values[UserItem.CSVImportFileItem.Column.DISPLAY_NAME], + values[UserItem.CSVImportFileItem.Column.EMAIL], + values[UserItem.CSVImportFileItem.Column.AUTH], + None, + ) return user @staticmethod @@ -344,7 +346,7 @@ def _validate_username_or_throw(username) -> None: raise AttributeError("Username cannot contain spaces") at_symbol = username.find("@") if at_symbol >= 0: - username = username[:at_symbol] + "X" + username[at_symbol + 1:] + username = username[:at_symbol] + "X" + username[at_symbol + 1 :] if username.find("@") >= 0: raise AttributeError("Username cannot repeat '@'") @@ -357,12 +359,10 @@ def _validate_imported_attributes_or_throw(incoming, logger) -> None: logger.debug("> details - {}".format(username)) UserItem.CSVImportFileItem._validate_username_or_throw(username) for i in range(1, len(line)): - logger.debug("column {}: {}".format(UserItem.CSVImportFileItem.Column(i).name, - line[i])) + logger.debug("column {}: {}".format(UserItem.CSVImportFileItem.Column(i).name, line[i])) UserItem.CSVImportFileItem._validate_item( - line[i], - UserItem.CHOICES[i], - UserItem.CSVImportFileItem.Column(i)) + line[i], UserItem.CHOICES[i], UserItem.CSVImportFileItem.Column(i) + ) @staticmethod def _validate_item(item: str, possible_values: List[str], column_type) -> None: @@ -371,7 +371,7 @@ def _validate_item(item: str, possible_values: List[str], column_type) -> None: return if item in possible_values or possible_values == []: return - raise AttributeError("Invalid value {} for {}").format(item, column_type) + raise AttributeError("Invalid value {} for {}".format(item, column_type)) # https://help.tableau.com/current/server/en-us/csvguidelines.htm#settings_and_site_roles @staticmethod @@ -410,4 +410,3 @@ def evaluate_site_role(license_level, admin_level, publisher): if site_role is None: site_role = "Unlicensed" return site_role - diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 8e8e9a341..f6eb43d19 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -2,7 +2,7 @@ class ServerResponseError(Exception): - def __init__(self, code, summary, detail, url = None): + def __init__(self, code, summary, detail, url=None): self.code = code self.summary = summary self.detail = detail @@ -21,7 +21,7 @@ def from_response(cls, resp, ns, url=None): parsed_response.find("t:error", namespaces=ns).get("code", ""), parsed_response.find(".//t:summary", namespaces=ns).text, parsed_response.find(".//t:detail", namespaces=ns).text, - url + url, ) except Exception as e: raise NonXMLResponseError(resp) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index e0142f16d..0e5d75b9a 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -5,14 +5,7 @@ from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError, ServerResponseError -from .. import ( - RequestFactory, - RequestOptions, - UserItem, - WorkbookItem, - PaginationItem, - GroupItem -) +from .. import RequestFactory, RequestOptions, UserItem, WorkbookItem, PaginationItem, GroupItem from ..pager import Pager # duplicate defined in workbooks_endpoint @@ -106,7 +99,7 @@ def add_all(self, users: List[UserItem]): # helping the user by parsing a file they could have used to add users through the UI # line format: Username [required], password, display name, license, admin, publish @api(version="2.0") - def create_from_file(self, filepath: FilePath = None) -> (List[UserItem], List[UserItem]): + def create_from_file(self, filepath: str) -> Tuple[List[UserItem], List[Tuple[UserItem, ServerResponseError]]]: created = [] failed = [] if not filepath.find("csv"): diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index fa0b338c8..c5613b2d6 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -14,6 +14,8 @@ def to_camel_case(word: str) -> str: e.g server.users.get(name="user@domain.com") see pagination_sample """ + + class QuerySet: def __init__(self, model): self.model = model diff --git a/test/test_user.py b/test/test_user.py index f829f24c5..1f5eba57f 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -23,6 +23,7 @@ USERNAMES = os.path.join(TEST_ASSET_DIR, "Data", "usernames.csv") USERS = os.path.join(TEST_ASSET_DIR, "Data", "user_details.csv") + class UserTests(unittest.TestCase): def setUp(self) -> None: self.server = TSC.Server("http://test", False) diff --git a/test/test_user_model.py b/test/test_user_model.py index 8affebed9..657d5d3fc 100644 --- a/test/test_user_model.py +++ b/test/test_user_model.py @@ -132,6 +132,7 @@ def test_validate_import_file(self): valid, invalid = TSC.UserItem.CSVImportFileItem.validate_file_for_import(test_data, UserDataTest.logger) assert valid == 2, "Expected two lines to be parsed, got {}".format(valid) assert invalid == 0, "Expected no failures, got {}".format(invalid) + def test_validate_usernames_file(self): test_data = self._mock_file_content(UserDataTest.usernames) valid, invalid = TSC.UserItem.CSVImportFileItem.validate_file_for_import(test_data, UserDataTest.logger) diff --git a/versioneer.py b/versioneer.py index 59211ed6f..86c240e13 100755 --- a/versioneer.py +++ b/versioneer.py @@ -277,6 +277,7 @@ """ from __future__ import print_function + try: import configparser except ImportError: @@ -308,11 +309,13 @@ def get_root(): setup_py = os.path.join(root, "setup.py") versioneer_py = os.path.join(root, "versioneer.py") if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)): - err = ("Versioneer was unable to run the project root directory. " - "Versioneer requires setup.py to be executed from " - "its immediate directory (like 'python setup.py COMMAND'), " - "or in a way that lets it use sys.argv[0] to find the root " - "(like 'python path/to/setup.py COMMAND').") + err = ( + "Versioneer was unable to run the project root directory. " + "Versioneer requires setup.py to be executed from " + "its immediate directory (like 'python setup.py COMMAND'), " + "or in a way that lets it use sys.argv[0] to find the root " + "(like 'python path/to/setup.py COMMAND')." + ) raise VersioneerBadRootError(err) try: # Certain runtime workflows (setup.py install/develop in a setuptools @@ -325,8 +328,7 @@ def get_root(): me_dir = os.path.normcase(os.path.splitext(me)[0]) vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0]) if me_dir != vsr_dir: - print("Warning: build in %s is using versioneer.py from %s" - % (os.path.dirname(me), versioneer_py)) + print("Warning: build in %s is using versioneer.py from %s" % (os.path.dirname(me), versioneer_py)) except NameError: pass return root @@ -348,6 +350,7 @@ def get(parser, name): if parser.has_option("versioneer", name): return parser.get("versioneer", name) return None + cfg = VersioneerConfig() cfg.VCS = VCS cfg.style = get(parser, "style") or "" @@ -372,17 +375,18 @@ class NotThisMethod(Exception): def register_vcs_handler(vcs, method): # decorator """Decorator to mark a method as the handler for a particular VCS.""" + def decorate(f): """Store f in HANDLERS[vcs][method].""" if vcs not in HANDLERS: HANDLERS[vcs] = {} HANDLERS[vcs][method] = f return f + return decorate -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, - env=None): +def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=None): """Call the given command(s).""" assert isinstance(commands, list) p = None @@ -390,10 +394,9 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, try: dispcmd = str([c] + args) # remember shell=False, so use git.cmd on windows, not just git - p = subprocess.Popen([c] + args, cwd=cwd, env=env, - stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr - else None)) + p = subprocess.Popen( + [c] + args, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=(subprocess.PIPE if hide_stderr else None) + ) break except EnvironmentError: e = sys.exc_info()[1] @@ -418,7 +421,9 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, return stdout, p.returncode -LONG_VERSION_PY['git'] = ''' +LONG_VERSION_PY[ + "git" +] = ''' # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (built by setup.py sdist) and build @@ -993,7 +998,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) + tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -1002,7 +1007,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r'\d', r)]) + tags = set([r for r in refs if re.search(r"\d", r)]) if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -1010,19 +1015,26 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): for ref in sorted(tags): # sorting will prefer e.g. "2.0" over "2.0rc1" if ref.startswith(tag_prefix): - r = ref[len(tag_prefix):] + r = ref[len(tag_prefix) :] if verbose: print("picking %s" % r) - return {"version": r, - "full-revisionid": keywords["full"].strip(), - "dirty": False, "error": None, - "date": date} + return { + "version": r, + "full-revisionid": keywords["full"].strip(), + "dirty": False, + "error": None, + "date": date, + } # no suitable tags, so version is "0+unknown", but full hex is still there if verbose: print("no suitable tags, using unknown + full revision id") - return {"version": "0+unknown", - "full-revisionid": keywords["full"].strip(), - "dirty": False, "error": "no suitable tags", "date": None} + return { + "version": "0+unknown", + "full-revisionid": keywords["full"].strip(), + "dirty": False, + "error": "no suitable tags", + "date": None, + } @register_vcs_handler("git", "pieces_from_vcs") @@ -1037,8 +1049,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if sys.platform == "win32": GITS = ["git.cmd", "git.exe"] - out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, - hide_stderr=True) + out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True) if rc != 0: if verbose: print("Directory %s not under git control" % root) @@ -1046,10 +1057,9 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", - "--always", "--long", - "--match", "%s*" % tag_prefix], - cwd=root) + describe_out, rc = run_command( + GITS, ["describe", "--tags", "--dirty", "--always", "--long", "--match", "%s*" % tag_prefix], cwd=root + ) # --long was added in git-1.5.5 if describe_out is None: raise NotThisMethod("'git describe' failed") @@ -1072,17 +1082,16 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): dirty = git_describe.endswith("-dirty") pieces["dirty"] = dirty if dirty: - git_describe = git_describe[:git_describe.rindex("-dirty")] + git_describe = git_describe[: git_describe.rindex("-dirty")] # now we have TAG-NUM-gHEX or HEX if "-" in git_describe: # TAG-NUM-gHEX - mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) + mo = re.search(r"^(.+)-(\d+)-g([0-9a-f]+)$", git_describe) if not mo: # unparseable. Maybe git-describe is misbehaving? - pieces["error"] = ("unable to parse git-describe output: '%s'" - % describe_out) + pieces["error"] = "unable to parse git-describe output: '%s'" % describe_out return pieces # tag @@ -1091,10 +1100,9 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" - % (full_tag, tag_prefix)) + pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % (full_tag, tag_prefix) return pieces - pieces["closest-tag"] = full_tag[len(tag_prefix):] + pieces["closest-tag"] = full_tag[len(tag_prefix) :] # distance: number of commits since tag pieces["distance"] = int(mo.group(2)) @@ -1105,13 +1113,11 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): else: # HEX: no tags pieces["closest-tag"] = None - count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], - cwd=root) + count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], cwd=root) pieces["distance"] = int(count_out) # total number of commits # commit date: see ISO-8601 comment in git_versions_from_keywords() - date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], - cwd=root)[0].strip() + date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) return pieces @@ -1167,16 +1173,19 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): for i in range(3): dirname = os.path.basename(root) if dirname.startswith(parentdir_prefix): - return {"version": dirname[len(parentdir_prefix):], - "full-revisionid": None, - "dirty": False, "error": None, "date": None} + return { + "version": dirname[len(parentdir_prefix) :], + "full-revisionid": None, + "dirty": False, + "error": None, + "date": None, + } else: rootdirs.append(root) root = os.path.dirname(root) # up a level if verbose: - print("Tried directories %s but none started with prefix %s" % - (str(rootdirs), parentdir_prefix)) + print("Tried directories %s but none started with prefix %s" % (str(rootdirs), parentdir_prefix)) raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @@ -1205,11 +1214,9 @@ def versions_from_file(filename): contents = f.read() except EnvironmentError: raise NotThisMethod("unable to read _version.py") - mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", - contents, re.M | re.S) + mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", contents, re.M | re.S) if not mo: - mo = re.search(r"version_json = '''\r\n(.*)''' # END VERSION_JSON", - contents, re.M | re.S) + mo = re.search(r"version_json = '''\r\n(.*)''' # END VERSION_JSON", contents, re.M | re.S) if not mo: raise NotThisMethod("no version_json in _version.py") return json.loads(mo.group(1)) @@ -1218,8 +1225,7 @@ def versions_from_file(filename): def write_to_version_file(filename, versions): """Write the given version number to the given _version.py file.""" os.unlink(filename) - contents = json.dumps(versions, sort_keys=True, - indent=1, separators=(",", ": ")) + contents = json.dumps(versions, sort_keys=True, indent=1, separators=(",", ": ")) with open(filename, "w") as f: f.write(SHORT_VERSION_PY % contents) @@ -1251,8 +1257,7 @@ def render_pep440(pieces): rendered += ".dirty" else: # exception #1 - rendered = "0+untagged.%d.g%s" % (pieces["distance"], - pieces["short"]) + rendered = "0+untagged.%d.g%s" % (pieces["distance"], pieces["short"]) if pieces["dirty"]: rendered += ".dirty" return rendered @@ -1366,11 +1371,13 @@ def render_git_describe_long(pieces): def render(pieces, style): """Render the given version pieces into the requested style.""" if pieces["error"]: - return {"version": "unknown", - "full-revisionid": pieces.get("long"), - "dirty": None, - "error": pieces["error"], - "date": None} + return { + "version": "unknown", + "full-revisionid": pieces.get("long"), + "dirty": None, + "error": pieces["error"], + "date": None, + } if not style or style == "default": style = "pep440" # the default @@ -1390,9 +1397,13 @@ def render(pieces, style): else: raise ValueError("unknown style '%s'" % style) - return {"version": rendered, "full-revisionid": pieces["long"], - "dirty": pieces["dirty"], "error": None, - "date": pieces.get("date")} + return { + "version": rendered, + "full-revisionid": pieces["long"], + "dirty": pieces["dirty"], + "error": None, + "date": pieces.get("date"), + } class VersioneerBadRootError(Exception): @@ -1415,8 +1426,7 @@ def get_versions(verbose=False): handlers = HANDLERS.get(cfg.VCS) assert handlers, "unrecognized VCS '%s'" % cfg.VCS verbose = verbose or cfg.verbose - assert cfg.versionfile_source is not None, \ - "please set versioneer.versionfile_source" + assert cfg.versionfile_source is not None, "please set versioneer.versionfile_source" assert cfg.tag_prefix is not None, "please set versioneer.tag_prefix" versionfile_abs = os.path.join(root, cfg.versionfile_source) @@ -1470,9 +1480,13 @@ def get_versions(verbose=False): if verbose: print("unable to compute version") - return {"version": "0+unknown", "full-revisionid": None, - "dirty": None, "error": "unable to compute version", - "date": None} + return { + "version": "0+unknown", + "full-revisionid": None, + "dirty": None, + "error": "unable to compute version", + "date": None, + } def get_version(): @@ -1521,6 +1535,7 @@ def run(self): print(" date: %s" % vers.get("date")) if vers["error"]: print(" error: %s" % vers["error"]) + cmds["version"] = cmd_version # we override "build_py" in both distutils and setuptools @@ -1553,14 +1568,15 @@ def run(self): # now locate _version.py in the new build/ directory and replace # it with an updated value if cfg.versionfile_build: - target_versionfile = os.path.join(self.build_lib, - cfg.versionfile_build) + target_versionfile = os.path.join(self.build_lib, cfg.versionfile_build) print("UPDATING %s" % target_versionfile) write_to_version_file(target_versionfile, versions) + cmds["build_py"] = cmd_build_py if "cx_Freeze" in sys.modules: # cx_freeze enabled? from cx_Freeze.dist import build_exe as _build_exe + # nczeczulin reports that py2exe won't like the pep440-style string # as FILEVERSION, but it can be used for PRODUCTVERSION, e.g. # setup(console=[{ @@ -1581,17 +1597,21 @@ def run(self): os.unlink(target_versionfile) with open(cfg.versionfile_source, "w") as f: LONG = LONG_VERSION_PY[cfg.VCS] - f.write(LONG % - {"DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - }) + f.write( + LONG + % { + "DOLLAR": "$", + "STYLE": cfg.style, + "TAG_PREFIX": cfg.tag_prefix, + "PARENTDIR_PREFIX": cfg.parentdir_prefix, + "VERSIONFILE_SOURCE": cfg.versionfile_source, + } + ) + cmds["build_exe"] = cmd_build_exe del cmds["build_py"] - if 'py2exe' in sys.modules: # py2exe enabled? + if "py2exe" in sys.modules: # py2exe enabled? try: from py2exe.distutils_buildexe import py2exe as _py2exe # py3 except ImportError: @@ -1610,13 +1630,17 @@ def run(self): os.unlink(target_versionfile) with open(cfg.versionfile_source, "w") as f: LONG = LONG_VERSION_PY[cfg.VCS] - f.write(LONG % - {"DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - }) + f.write( + LONG + % { + "DOLLAR": "$", + "STYLE": cfg.style, + "TAG_PREFIX": cfg.tag_prefix, + "PARENTDIR_PREFIX": cfg.parentdir_prefix, + "VERSIONFILE_SOURCE": cfg.versionfile_source, + } + ) + cmds["py2exe"] = cmd_py2exe # we override different "sdist" commands for both environments @@ -1643,8 +1667,8 @@ def make_release_tree(self, base_dir, files): # updated value target_versionfile = os.path.join(base_dir, cfg.versionfile_source) print("UPDATING %s" % target_versionfile) - write_to_version_file(target_versionfile, - self._versioneer_generated_versions) + write_to_version_file(target_versionfile, self._versioneer_generated_versions) + cmds["sdist"] = cmd_sdist return cmds @@ -1699,11 +1723,9 @@ def do_setup(): root = get_root() try: cfg = get_config_from_root(root) - except (EnvironmentError, configparser.NoSectionError, - configparser.NoOptionError) as e: + except (EnvironmentError, configparser.NoSectionError, configparser.NoOptionError) as e: if isinstance(e, (EnvironmentError, configparser.NoSectionError)): - print("Adding sample versioneer config to setup.cfg", - file=sys.stderr) + print("Adding sample versioneer config to setup.cfg", file=sys.stderr) with open(os.path.join(root, "setup.cfg"), "a") as f: f.write(SAMPLE_CONFIG) print(CONFIG_ERROR, file=sys.stderr) @@ -1712,15 +1734,18 @@ def do_setup(): print(" creating %s" % cfg.versionfile_source) with open(cfg.versionfile_source, "w") as f: LONG = LONG_VERSION_PY[cfg.VCS] - f.write(LONG % {"DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - }) - - ipy = os.path.join(os.path.dirname(cfg.versionfile_source), - "__init__.py") + f.write( + LONG + % { + "DOLLAR": "$", + "STYLE": cfg.style, + "TAG_PREFIX": cfg.tag_prefix, + "PARENTDIR_PREFIX": cfg.parentdir_prefix, + "VERSIONFILE_SOURCE": cfg.versionfile_source, + } + ) + + ipy = os.path.join(os.path.dirname(cfg.versionfile_source), "__init__.py") if os.path.exists(ipy): try: with open(ipy, "r") as f: @@ -1762,8 +1787,7 @@ def do_setup(): else: print(" 'versioneer.py' already in MANIFEST.in") if cfg.versionfile_source not in simple_includes: - print(" appending versionfile_source ('%s') to MANIFEST.in" % - cfg.versionfile_source) + print(" appending versionfile_source ('%s') to MANIFEST.in" % cfg.versionfile_source) with open(manifest_in, "a") as f: f.write("include %s\n" % cfg.versionfile_source) else: From 81d5f160bd66e24475f962d47cf5064c099b7d87 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 5 Aug 2022 12:57:40 -0700 Subject: [PATCH 07/10] Update user_item.py Added method comments, renamed to make private methods clearer, deleted duplicate as_reference method --- tableauserverclient/models/user_item.py | 129 ++++++++++++------------ 1 file changed, 67 insertions(+), 62 deletions(-) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 59b1ab8e6..032841dc7 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -14,7 +14,7 @@ from .reference_item import ResourceReference from ..datetime_helpers import parse_datetime -from typing import Dict, List, Optional, TYPE_CHECKING +from typing import Dict, List, Optional, TYPE_CHECKING, Tuple if TYPE_CHECKING: from ..server.pager import Pager @@ -69,6 +69,10 @@ def __init__( return None + def __repr__(self) -> str: + str_site_role = self.site_role or "None" + return "".format(self.id, self.name, str_site_role) + @property def auth_setting(self) -> Optional[str]: return self._auth_setting @@ -103,6 +107,19 @@ def name(self) -> Optional[str]: def name(self, value: str): self._name = value + # valid: username, domain/username, username@domain, domain/username@email + @staticmethod + def validate_username_or_throw(username) -> None: + if username is None or username == "" or username.strip(" ") == "": + raise AttributeError("Username cannot be empty") + if username.find(" ") >= 0: + raise AttributeError("Username cannot contain spaces") + at_symbol = username.find("@") + if at_symbol >= 0: + username = username[:at_symbol] + "X" + username[at_symbol + 1 :] + if username.find("@") >= 0: + raise AttributeError("Username cannot repeat '@'") + @property def site_role(self) -> Optional[str]: return self._site_role @@ -133,9 +150,6 @@ def groups(self) -> "Pager": raise UnpopulatedPropertyError(error) return self._groups() - def to_reference(self) -> ResourceReference: - return ResourceReference(id_=self.id, tag_name=self.tag_name) - def _set_workbooks(self, workbooks) -> None: self._workbooks = workbooks @@ -255,27 +269,14 @@ def _parse_element(user_xml, ns): domain_name, ) - def __repr__(self) -> str: - str_site_role = self.site_role or "None" - return "".format(self.id, self.name, str_site_role) + class CSVImport(object): + """ + This class includes hardcoded options and logic for the CSV file format defined for user import + https://help.tableau.com/current/server/en-us/users_import.htm + """ - # valid values for each field - CHOICES: List[List[str]] = [ - [], - [], - [], - ["creator", "explorer", "viewer", "unlicensed"], # license - ["system", "site", "none", "no"], # admin - ["yes", "true", "1", "no", "false", "0"], # publisher - [], - [Auth.SAML, Auth.OpenID, Auth.ServerDefault], # auth - ] - - class CSVImportFileItem(object): - - # CSV import file format # username, password, display_name, license, admin_level, publishing, email, auth type - class Column(IntEnum): + class ColumnType(IntEnum): USERNAME = 0 PASS = 1 DISPLAY_NAME = 2 @@ -287,85 +288,88 @@ class Column(IntEnum): MAX = 7 + # Read a csv line and create a user item populated by the given attributes @staticmethod - def _create_from_csv_line(line: str): + def create_user_from_line(line: str): if line is None or line is False or line == "\n" or line == "": return None line = line.strip().lower() values: List[str] = list(map(str.strip, line.split(","))) - user = UserItem(values[UserItem.CSVImportFileItem.Column.USERNAME]) + user = UserItem(values[UserItem.CSVImport.ColumnType.USERNAME]) if len(values) > 1: - if len(values) > UserItem.CSVImportFileItem.Column.MAX: + if len(values) > UserItem.CSVImport.ColumnType.MAX: raise ValueError("Too many attributes for user import") - while len(values) <= UserItem.CSVImportFileItem.Column.MAX: + while len(values) <= UserItem.CSVImport.ColumnType.MAX: values.append("") - site_role = UserItem.CSVImportFileItem.evaluate_site_role( - values[UserItem.CSVImportFileItem.Column.LICENSE], - values[UserItem.CSVImportFileItem.Column.ADMIN], - values[UserItem.CSVImportFileItem.Column.PUBLISHER], + site_role = UserItem.CSVImport._evaluate_site_role( + values[UserItem.CSVImport.ColumnType.LICENSE], + values[UserItem.CSVImport.ColumnType.ADMIN], + values[UserItem.CSVImport.ColumnType.PUBLISHER], ) user._set_values( None, - values[UserItem.CSVImportFileItem.Column.USERNAME], + values[UserItem.CSVImport.ColumnType.USERNAME], site_role, None, None, - values[UserItem.CSVImportFileItem.Column.DISPLAY_NAME], - values[UserItem.CSVImportFileItem.Column.EMAIL], - values[UserItem.CSVImportFileItem.Column.AUTH], + values[UserItem.CSVImport.ColumnType.DISPLAY_NAME], + values[UserItem.CSVImport.ColumnType.EMAIL], + values[UserItem.CSVImport.ColumnType.AUTH], None, ) return user + # Read through an entire CSV file meant for user import + # Return the number of valid lines and a list of all the invalid lines @staticmethod - def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> List[int]: - num_errors = 0 + def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> Tuple[int, List[str]]: num_valid_lines = 0 + invalid_lines = [] csv_file.seek(0) # set to start of file in case it has been read earlier line: str = csv_file.readline() while line and line != "": try: # do not print passwords logger.info("Reading user {}".format(line[:4])) - UserItem.CSVImportFileItem._validate_imported_attributes_or_throw(line, logger) + UserItem.CSVImport._validate_import_line_or_throw(line, logger) num_valid_lines += 1 except Exception as exc: logger.info("Error parsing {}: {}".format(line[:4], exc)) - num_errors += 1 + invalid_lines.append(line) line = csv_file.readline() - return [num_valid_lines, num_errors] + return num_valid_lines, invalid_lines - # this might belong in the main User class - # valid: username, domain/username, username@domain, domain/username@email + # Some fields in the import file are restricted to specific values + # Iterate through each field and validate the given value against hardcoded constraints @staticmethod - def _validate_username_or_throw(username) -> None: - if username is None or username == "" or username.strip(" ") == "": - raise AttributeError("Username cannot be empty") - if username.find(" ") >= 0: - raise AttributeError("Username cannot contain spaces") - at_symbol = username.find("@") - if at_symbol >= 0: - username = username[:at_symbol] + "X" + username[at_symbol + 1 :] - if username.find("@") >= 0: - raise AttributeError("Username cannot repeat '@'") + def _validate_import_line_or_throw(incoming, logger) -> None: + _valid_attributes: List[List[str]] = [ + [], + [], + [], + ["creator", "explorer", "viewer", "unlicensed"], # license + ["system", "site", "none", "no"], # admin + ["yes", "true", "1", "no", "false", "0"], # publisher + [], + [UserItem.Auth.SAML, UserItem.Auth.OpenID, UserItem.Auth.ServerDefault], # auth + ] - @staticmethod - def _validate_imported_attributes_or_throw(incoming, logger) -> None: line = list(map(str.strip, incoming.split(","))) - if len(line) > UserItem.CSVImportFileItem.Column.MAX: + if len(line) > UserItem.CSVImport.ColumnType.MAX: raise AttributeError("Too many attributes in line") - username = line[UserItem.CSVImportFileItem.Column.USERNAME.value] + username = line[UserItem.CSVImport.ColumnType.USERNAME.value] logger.debug("> details - {}".format(username)) - UserItem.CSVImportFileItem._validate_username_or_throw(username) + UserItem.validate_username_or_throw(username) for i in range(1, len(line)): - logger.debug("column {}: {}".format(UserItem.CSVImportFileItem.Column(i).name, line[i])) - UserItem.CSVImportFileItem._validate_item( - line[i], UserItem.CHOICES[i], UserItem.CSVImportFileItem.Column(i) + logger.debug("column {}: {}".format(UserItem.CSVImport.ColumnType(i).name, line[i])) + UserItem.CSVImport._validate_attribute_value( + line[i], _valid_attributes[i], UserItem.CSVImport.ColumnType(i) ) + # Given a restricted set of possible values, confirm the item is in that set @staticmethod - def _validate_item(item: str, possible_values: List[str], column_type) -> None: + def _validate_attribute_value(item: str, possible_values: List[str], column_type) -> None: if item is None or item == "": # value can be empty for any column except user, which is checked elsewhere return @@ -374,8 +378,9 @@ def _validate_item(item: str, possible_values: List[str], column_type) -> None: raise AttributeError("Invalid value {} for {}".format(item, column_type)) # https://help.tableau.com/current/server/en-us/csvguidelines.htm#settings_and_site_roles + # This logic is hardcoded to match the existing rules for import csv files @staticmethod - def evaluate_site_role(license_level, admin_level, publisher): + def _evaluate_site_role(license_level, admin_level, publisher): if not license_level or not admin_level or not publisher: return "Unlicensed" # ignore case everywhere From b571adf6ecabed8c6d660a22e57f6f650b13b45f Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 5 Aug 2022 12:57:53 -0700 Subject: [PATCH 08/10] deleted duplicate group.as_reference method --- tableauserverclient/models/group_item.py | 3 --- test/test_project.py | 7 ++++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index c84223b5a..eb03b1b5d 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -79,9 +79,6 @@ def users(self) -> "Pager": # Each call to `.users` should create a new pager, this just runs the callable return self._users() - def to_reference(self) -> ResourceReference: - return ResourceReference(id_=self.id, tag_name=self.tag_name) - def _set_users(self, users: Callable[..., "Pager"]) -> None: self._users = users diff --git a/test/test_project.py b/test/test_project.py index 1d210eeb1..48e6005af 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -4,6 +4,7 @@ import requests_mock import tableauserverclient as TSC +from tableauserverclient import GroupItem from ._utils import read_xml_asset, asset TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -120,7 +121,7 @@ def test_update_datasource_default_permission(self) -> None: capabilities = {TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny} - rules = [TSC.PermissionsRule(grantee=group.to_reference(), capabilities=capabilities)] + rules = [TSC.PermissionsRule(grantee=GroupItem.as_reference(group._id), capabilities=capabilities)] new_rules = self.server.projects.update_datasource_default_permissions(project, rules) @@ -237,7 +238,7 @@ def test_delete_permission(self) -> None: if permission.grantee.id == single_group._id: capabilities = permission.capabilities - rules = TSC.PermissionsRule(grantee=single_group.to_reference(), capabilities=capabilities) + rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) endpoint = "{}/permissions/groups/{}".format(single_project._id, single_group._id) m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) @@ -283,7 +284,7 @@ def test_delete_workbook_default_permission(self) -> None: TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Allow, } - rules = TSC.PermissionsRule(grantee=single_group.to_reference(), capabilities=capabilities) + rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) endpoint = "{}/default-permissions/workbooks/groups/{}".format(single_project._id, single_group._id) m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) From 81c58f1faed54902e2e34de0cad325d24f01c2bc Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 5 Aug 2022 12:58:07 -0700 Subject: [PATCH 09/10] update code and tests for user changes --- .../server/endpoint/users_endpoint.py | 2 +- test/test_user_model.py | 34 +++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 0e5d75b9a..28406ab71 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -109,7 +109,7 @@ def create_from_file(self, filepath: str) -> Tuple[List[UserItem], List[Tuple[Us csv_file.seek(0) # set to start of file in case it has been read earlier line: str = csv_file.readline() while line and line != "": - user: UserItem = UserItem.CSVImportFileItem._create_from_csv_line(line) + user: UserItem = UserItem.CSVImport.create_user_from_line(line) try: print(user) result = self.add(user) diff --git a/test/test_user_model.py b/test/test_user_model.py index 657d5d3fc..32d808f52 100644 --- a/test/test_user_model.py +++ b/test/test_user_model.py @@ -75,29 +75,29 @@ class UserDataTest(unittest.TestCase): ] def test_validate_usernames(self): - TSC.UserItem.CSVImportFileItem._validate_username_or_throw(UserDataTest.usernames[0]) - TSC.UserItem.CSVImportFileItem._validate_username_or_throw(UserDataTest.usernames[1]) - TSC.UserItem.CSVImportFileItem._validate_username_or_throw(UserDataTest.usernames[2]) - TSC.UserItem.CSVImportFileItem._validate_username_or_throw(UserDataTest.usernames[3]) - TSC.UserItem.CSVImportFileItem._validate_username_or_throw(UserDataTest.usernames[4]) + TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[0]) + TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[1]) + TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[2]) + TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[3]) + TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[4]) with self.assertRaises(AttributeError): - TSC.UserItem.CSVImportFileItem._validate_username_or_throw(UserDataTest.usernames[5]) + TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[5]) with self.assertRaises(AttributeError): - TSC.UserItem.CSVImportFileItem._validate_username_or_throw(UserDataTest.usernames[6]) + TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[6]) def test_evaluate_role(self): for line in UserDataTest.role_inputs: - actual = TSC.UserItem.CSVImportFileItem.evaluate_site_role(line[0], line[1], line[2]) + actual = TSC.UserItem.CSVImport._evaluate_site_role(line[0], line[1], line[2]) assert actual == line[3], line + [actual] def test_get_user_detail_empty_line(self): test_line = "" - test_user = TSC.UserItem.CSVImportFileItem._create_from_csv_line(test_line) + test_user = TSC.UserItem.CSVImport.create_user_from_line(test_line) assert test_user is None def test_get_user_detail_standard(self): test_line = "username, pword, fname, license, admin, pub, email" - test_user: TSC.UserItem = TSC.UserItem.CSVImportFileItem._create_from_csv_line(test_line) + test_user: TSC.UserItem = TSC.UserItem.CSVImport.create_user_from_line(test_line) assert test_user.name == "username", test_user.name assert test_user.fullname == "fname", test_user.fullname assert test_user.site_role == "Unlicensed", test_user.site_role @@ -105,17 +105,17 @@ def test_get_user_detail_standard(self): def test_get_user_details_only_username(self): test_line = "username" - test_user: TSC.UserItem = TSC.UserItem.CSVImportFileItem._create_from_csv_line(test_line) + test_user: TSC.UserItem = TSC.UserItem.CSVImport.create_user_from_line(test_line) def test_populate_user_details_only_some(self): values = "username, , , creator, admin" - user = TSC.UserItem.CSVImportFileItem._create_from_csv_line(values) + user = TSC.UserItem.CSVImport.create_user_from_line(values) assert user.name == "username" def test_validate_user_detail_standard(self): test_line = "username, pword, fname, creator, site, 1, email" - TSC.UserItem.CSVImportFileItem._validate_imported_attributes_or_throw(test_line, UserDataTest.logger) - TSC.UserItem.CSVImportFileItem._create_from_csv_line(test_line) + TSC.UserItem.CSVImport._validate_import_line_or_throw(test_line, UserDataTest.logger) + TSC.UserItem.CSVImport.create_user_from_line(test_line) # for file handling def _mock_file_content(self, content: List[str]) -> io.TextIOWrapper: @@ -129,11 +129,11 @@ def _mock_file_content(self, content: List[str]) -> io.TextIOWrapper: def test_validate_import_file(self): test_data = self._mock_file_content(UserDataTest.valid_import_content) - valid, invalid = TSC.UserItem.CSVImportFileItem.validate_file_for_import(test_data, UserDataTest.logger) + valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) assert valid == 2, "Expected two lines to be parsed, got {}".format(valid) - assert invalid == 0, "Expected no failures, got {}".format(invalid) + assert invalid == [], "Expected no failures, got {}".format(invalid) def test_validate_usernames_file(self): test_data = self._mock_file_content(UserDataTest.usernames) - valid, invalid = TSC.UserItem.CSVImportFileItem.validate_file_for_import(test_data, UserDataTest.logger) + valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) assert valid == 5, "Exactly 5 of the lines were valid, counted {}".format(valid + invalid) From 708fa431c6dc1acbc02ae25a277416daa08f5a75 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 26 Aug 2022 20:26:19 -0700 Subject: [PATCH 10/10] format --- tableauserverclient/server/endpoint/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 45a00f749..3ce0d5e92 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -33,7 +33,7 @@ def from_response(cls, resp, ns, url=None): class InternalServerError(TableauError): - def __init__(self, server_response, request_url: str=None): + def __init__(self, server_response, request_url: str = None): self.code = server_response.status_code self.content = server_response.content self.url = request_url or "server"