Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/run-e2-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
fail-fast: true
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ['3.7', '3.8', '3.9', '3.10', '3']
python-version: ['3.9', '3.10', '3']

runs-on: ${{ matrix.os }}

Expand Down
5 changes: 2 additions & 3 deletions tabcmd/commands/auth/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import requests
import tableauserverclient as TSC
import tableauserverclient.server.endpoint.exceptions
from urllib3.exceptions import InsecureRequestWarning

from tabcmd.commands.constants import Errors
Expand Down Expand Up @@ -45,8 +44,8 @@ def __init__(self):
self.timeout = None

self.logging_level = "info"
self.logger = log(__class__.__name__, self.logging_level)
self._read_from_json()
self.logger = log(__name__, self.logging_level) # instantiate here mostly for tests
self.tableau_server = None # this one is an object that doesn't get persisted in the file

# called before we connect to the server
Expand All @@ -55,7 +54,6 @@ def _update_session_data(self, args):
# user id and site id are never passed in as args
# last_login_using and tableau_server are internal data
# self.command = args.???
# TODO: if server/username/token are changed, clear others
self.username = args.username or self.username
self.site_name = args.site_name or self.site_name or ""
if self.site_name == "default":
Expand Down Expand Up @@ -216,6 +214,7 @@ def create_session(self, args):
self._read_existing_state()
self._update_session_data(args)
self.logging_level = args.logging_level or self.logging_level
self.logger = log(__name__, self.logging_level)

credentials = None
if args.password:
Expand Down
22 changes: 22 additions & 0 deletions tabcmd/commands/constants.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import inspect
import sys

from tabcmd.execution.localize import _
Expand Down Expand Up @@ -29,9 +30,30 @@ def is_login_error(error):
if hasattr(error, "code"):
return error.code == Constants.login_error

# https://gist.github.com/FredLoney/5454553
@staticmethod
def log_stack(logger):
try:
"""The log header message formatter."""
HEADER_FMT = "Printing Call Stack at %s::%s"
"""The log stack message formatter."""
STACK_FMT = "%s, line %d in function %s."
stack = inspect.stack()
here = stack[0]
file, line, func = here[1:4]
start = 0
n_lines = 5
logger.trace(HEADER_FMT % (file, func))
for frame in stack[start + 1 : n_lines]:
file, line, func = frame[1:4]
logger.trace(STACK_FMT % (file, line, func))
except BaseException as e:
logger.info("Error printing stack trace:", e)

@staticmethod
def exit_with_error(logger, message=None, exception=None):
try:
Errors.log_stack(logger)
if message and not exception:
logger.error(message)
if exception:
Expand Down
91 changes: 60 additions & 31 deletions tabcmd/commands/datasources_and_workbooks/export_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,25 +38,6 @@ def define_args(export_parser):
help="View filter to apply to the view",
)

# TODO: ARGUMENT --COMPLETE

@staticmethod
def get_content_url_for_workbook(url):
# check the size of list
separated_list = url.split("/")
reversed_list = separated_list[::-1]
return reversed_list[1]

@staticmethod
def get_content_url_for_view(url):
# check the size of list
separated_list = url.split("/")
if len(separated_list) > 2:
print("error")
workbook_name = separated_list[0]
view_name = separated_list[1]
return DatasourcesAndWorkbooks.get_view_url_from_names(workbook_name, view_name)

"""
Command to Export a view_name or workbook from Tableau Server and save
it to a file. This command can also export just the data used for a view_name
Expand All @@ -73,57 +54,95 @@ def run_command(args):
if not view_content_url and not wb_content_url:
Errors.exit_with_error(logger, _("export.errors.requires_workbook_view_param").format(ExportCommand))

try:
logger.debug(args.pagelayout, args.pagesize, args.filename, args.width, args.height, args.filter)

try:
if args.fullpdf: # it's a workbook
workbook_item = ExportCommand.get_wb_by_content_url(logger, server, wb_content_url)
output = ExportCommand.download_wb_pdf(server, workbook_item)
output = ExportCommand.download_wb_pdf(server, workbook_item, args.url, logger)

default_filename = "{}.pdf".format(workbook_item.name)

elif args.pdf or args.png or args.csv: # it's a view
view_item = ExportCommand.get_view_by_content_url(logger, server, view_content_url)

if args.pdf:
output = ExportCommand.download_view_pdf(server, view_item)
output = ExportCommand.download_view_pdf(server, view_item, args.url, logger)
default_filename = "{}.pdf".format(view_item.name)
elif args.csv:
output = ExportCommand.download_csv(server, view_item)
output = ExportCommand.download_csv(server, view_item, args.url, logger)
default_filename = "{}.csv".format(view_item.name)
elif args.png:
output = ExportCommand.download_png(server, view_item)
output = ExportCommand.download_png(server, view_item, args.url, logger)

default_filename = "{}.png".format(view_item.name)

except Exception as e:
Errors.exit_with_error(logger, _("publish.errors.unexpected_server_response").format(""), e)

try:
save_name = args.filename or default_filename
ExportCommand.save_to_file(logger, output, save_name)
if args.csv:
ExportCommand.save_to_data_file(logger, output, save_name)
else:
ExportCommand.save_to_file(logger, output, save_name)


except Exception as e:
Errors.exit_with_error(logger, "Error saving to file", e)

@staticmethod
def download_wb_pdf(server, workbook_item):
def extract_filter_values_from_url_params(request_options: TSC.PDFRequestOptions, url, logger=None) -> None:
try:
# todo make logging better
logger = logger or log(ExportCommand.__class__.__name__, "DEBUG")
logger.debug(url)

if "?" in url:
query = url.split("?")[1]
else:
return

params = query.split("&")
logger.trace(params)
for value in params:
data_filter = value.split("=")
request_options.vf(data_filter[0], data_filter[1])
except BaseException as e:
logger.error("Error building filter params", e)
ExportCommand.log_stack(logger) # type: ignore

@staticmethod
def download_wb_pdf(server, workbook_item, url, logger):
logger.trace(url)
pdf = TSC.PDFRequestOptions(maxage=1)
ExportCommand.extract_filter_values_from_url_params(pdf, url)

server.workbooks.populate_pdf(workbook_item, pdf)
return workbook_item.pdf

@staticmethod
def download_view_pdf(server, view_item):
def download_view_pdf(server, view_item, url, logger):
logger.trace(url)
pdf = TSC.PDFRequestOptions(maxage=1)
ExportCommand.extract_filter_values_from_url_params(pdf, url)
logger.trace(pdf.view_filters)
server.views.populate_pdf(view_item, pdf)
return view_item.pdf

@staticmethod
def download_csv(server, view_item):
def download_csv(server, view_item, url, logger):
logger.trace(url)
csv = TSC.CSVRequestOptions(maxage=1)
ExportCommand.extract_filter_values_from_url_params(csv, url)
server.views.populate_csv(view_item, csv)
return view_item.csv

@staticmethod
def download_png(server, view_item):
def download_png(server, view_item, url, logger):
logger.trace(url)
req_option_image = TSC.ImageRequestOptions(maxage=1)
ExportCommand.extract_filter_values_from_url_params(req_option_image, url)
server.views.populate_image(view_item, req_option_image)
return view_item.png

Expand All @@ -132,7 +151,10 @@ def parse_export_url_to_workbook_and_view(logger, url):
logger.info(_("export.status").format(url))
if " " in url:
Errors.exit_with_error(logger, _("export.errors.white_space_workbook_view"))
# input should be workbook_name/view_name
if "?" in url:
url = url.split("?")[0]
# input should be workbook_name/view_name or /workbook_name/view_name
url = url.lstrip("/") # strip opening / if present
if not url.find("/"):
return None, None
name_parts = url.split("/")
Expand All @@ -142,9 +164,16 @@ def parse_export_url_to_workbook_and_view(logger, url):
view = "{}/sheets/{}".format(workbook, name_parts[1])
return view, workbook

@staticmethod
def save_to_data_file(logger, output, filename):
logger.info(_("httputils.found_attachment").format(filename))
with open(filename, "wb") as f:
f.writelines(output)
logger.info(_("export.success").format("", filename))

@staticmethod
def save_to_file(logger, output, filename):
logger.info(_("httputils.found_attachment").format(filename))
with open(filename, "wb") as f:
f.write(output)
logger.info(_("export.success").format(filename, ""))
logger.info(_("export.success").format("", filename))
11 changes: 9 additions & 2 deletions tabcmd/commands/datasources_and_workbooks/get_url_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ def run_command(args):
if " " in args.url:
Errors.exit_with_error(logger, _("export.errors.white_space_workbook_view"))

if not args.url.startswith("/"):
args.url = "/" + args.url
logger.trace("helpfully fix format of url: " + args.url)

file_type = GetUrl.get_file_type_from_filename(logger, args.filename, args.url)
content_type = GetUrl.evaluate_content_type(logger, args.url)
if content_type == "workbook":
Expand Down Expand Up @@ -65,9 +69,12 @@ def evaluate_content_type(logger, url):
elif url.find("/workbooks/") == 0:
return "workbook"
else:
Errors.exit_with_error(
logger, message=_("export.errors.requires_workbook_view_param").format(__class__.__name__)
view_example = "/views/<workbookname>/<viewname>.<extension>"
message = "{} [{}]".format(
_("export.errors.requires_workbook_view_param").format(__class__.__name__), view_example
)
Errors.exit_with_error(logger, message)


@staticmethod
def get_file_type_from_filename(logger, file_name, url):
Expand Down
2 changes: 1 addition & 1 deletion tabcmd/commands/group/delete_group_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def run_command(args):
server = session.create_session(args)
try:
logger.info(_("tabcmd.find.group").format(args.name))
group_id = Server.find_group_id(logger, server, args.name)
group_id = Server.find_group(logger, server, args.name).id
logger.info(_("deletegroup.status").format(group_id))
server.groups.delete(group_id)
logger.info(_("common.output.succeeded"))
Expand Down
25 changes: 12 additions & 13 deletions tabcmd/commands/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,29 +39,26 @@ def find_group(logger, server, group_name):
Errors.exit_with_error(logger, exception=e)

@staticmethod
def find_group_id(logger, server, group_name):
return Server.find_group(logger, server, group_name).id
def find_user(logger, server, username):
return Server.get_items_by_name(logger, server.users, username)[0]

@staticmethod
def find_user_id(logger, server, username):
try:
return Server.get_items_by_name(logger, server.users, username)[0].id
except Exception as e:
Errors.exit_with_error(logger, exception=e)

@staticmethod
def get_items_by_name(logger, item_endpoint, item_name: str, container: TSC.ProjectItem = None):
def get_items_by_name(logger, item_endpoint, item_name: str, container: TSC.ProjectItem = None) -> List:
# TODO: typing should reflect that this returns TSC.TableauItem and item_endpoint is of type TSC.QuerysetEndpoint[same]
item_type = type(item_endpoint).__name__
item_log_name: str = "[" + item_type + "] " + item_name
if container:
item_log_name = str(container) + "/" + item_log_name
logger.debug(_("export.status").format(item_log_name))
req_option = TSC.RequestOptions()
req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, item_name))

all_items, pagination_item = item_endpoint.get(req_option)
if all_items is None or all_items == []:
raise ValueError("[" + item_type + "] " + _("errors.xmlapi.not_found"))
raise TSC.ServerResponseError(
code=404,
summary=_("errors.xmlapi.not_found"),
detail=_("errors.xmlapi.not_found") + ": " + item_log_name,
)
if len(all_items) == 1:
logger.debug("Exactly one result found")
result = all_items
Expand Down Expand Up @@ -120,7 +117,7 @@ def get_project_by_name_and_parent_path(logger, server, project_name: str, paren
if not parent_path:
if not project_name:
project_name = "Default"
project: TSC.ProjectItem = Server.get_items_by_name(logger, server.projects, project_name, None)
project: TSC.ProjectItem = Server.get_items_by_name(logger, server.projects, project_name, None)[0]
return project

project_tree: List[str] = Server._parse_project_path_to_list(parent_path)
Expand All @@ -129,7 +126,9 @@ def get_project_by_name_and_parent_path(logger, server, project_name: str, paren
return project

parent = Server._get_parent_project_from_tree(logger, server, project_tree)
logger.debug(parent)
project = Server._get_project_by_name_and_parent(logger, server, project_name, parent)
logger.debug(project)
if not project:
Errors.exit_with_error(logger, message=_("publish.errors.server_resource_not_found"))
return project
Expand Down
2 changes: 1 addition & 1 deletion tabcmd/commands/user/delete_site_users_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def run_command(args):
for user_obj in user_obj_list:
logger.info(_("importcsvsummary.line.processed").format(number_of_users_deleted))
try:
user_id = UserCommand.find_user_id(logger, server, user_obj.name)
user_id = UserCommand.find_user(logger, server, user_obj.name).id
server.users.remove(user_id)
logger.debug(_("tabcmd.result.success.delete_user").format(user_obj.name, user_id))
number_of_users_deleted += 1
Expand Down
32 changes: 25 additions & 7 deletions tabcmd/commands/user/user_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,17 +229,25 @@ def act_on_users(
logger.debug(_("importcsvsummary.line.processed").format(n_users_listed))

error_list = []
line_no = 0
user_obj_list: List[TSC.UserItem] = UserCommand.get_users_from_file(args.users)
logger.debug(_("tabcmd.result.success.parsed_users").format(len(user_obj_list)))
for user_obj in user_obj_list:
username: str = user_obj.name or "unknown user"
line_no += 1
if not user_obj.name:
number_of_errors += 1
error_list.append(_("importcsvsummary.error.line").format(line_no, "No username", ""))
continue

try:
user_id: str = UserCommand.find_user_id(logger, server, username)
username: str = user_obj.name
user_id: str = UserCommand.find_user(logger, server, username).id
logger.debug("{} user {} ({})".format(action_name, username, user_id))
except TSC.ServerResponseError as e:
Errors.check_common_error_codes_and_explain(logger, e)
number_of_errors += 1
error_list.append(e)
error_list.append(
_("importcsvsummary.error.line").format(line_no, username, "{}: {}".format(e.code, e.detail))
)
logger.debug(_("tabcmd.result.failure.user").format(username))
continue

Expand All @@ -248,11 +256,21 @@ def act_on_users(
n_users_handled += 1
logger.info(_("tabcmd.result.success.user_actions").format(action_name, username, group))
except TSC.ServerResponseError as e:
Errors.check_common_error_codes_and_explain(logger, e)
number_of_errors += 1
error_list.append(e)
error_list.append(
_("importcsvsummary.error.line").format(line_no, username, "{}: {}".format(e.code, e.detail))
)

logger.info(_("session.monitorjob.percent_complete").format(100))
logger.info(_("importcsvsummary.errors.count").format(number_of_errors))
if number_of_errors > 0:
logger.info(_("importcsvsummary.error.details").format(error_list))
i = 0
max_printing = 5
logger.info(_("importcsvsummary.error.details"))
while i < number_of_errors and i < max_printing:
logger.info(error_list[i])
i += 1
if number_of_errors > max_printing:
logger.info(_("importcsvsummary.error.too_many_errors"))
logger.info(_("importcsvsummary.remainingerrors"))

Loading