Skip to content

Commit

Permalink
Merge pull request #313 from tableau/dev/feat-custom-views-export
Browse files Browse the repository at this point in the history
Adding support for custom view urls to export & get tabcmd commands
  • Loading branch information
renoyjohnm authored Nov 19, 2024
2 parents e1f80d3 + 00ae3e6 commit e2a6879
Show file tree
Hide file tree
Showing 24 changed files with 545 additions and 162 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/generate-metadata.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.7'
python-version: '3.9'

- name: Install App and Extras
run: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ jobs:

- uses: actions/setup-python@v5
with:
python-version: 3.8
python-version: 3.9

- name: Install dependencies and build
run: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: 3.8
python-version: 3.9
- name: Build dist files
run: |
python --version
Expand Down
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.9', '3.10', '3']
python-version: ['3.9', '3.10', '3.11', '3.12', '3']

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

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
fail-fast: true
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ['3.8', '3.9', '3.10', '3']
python-version: ['3.9', '3.10', '3.11', '3.12', '3']

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

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ dependencies = [
"types-mock",
"types-requests",
"types-setuptools",
"tableauserverclient==0.31",
"tableauserverclient==0.34",
"urllib3",
]
[project.optional-dependencies]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,14 @@ def save_to_file(logger, output, filename):
with open(filename, "wb") as f:
f.write(output)
logger.info(_("export.success").format("", filename))

@staticmethod
def get_custom_view_by_id(logger, server, custom_view_id) -> TSC.CustomViewItem:
logger.debug(_("export.status").format(custom_view_id))
try:
matching_custom_view = server.custom_views.get_by_id(custom_view_id)
except Exception as e:
Errors.exit_with_error(logger, exception=e)
if matching_custom_view is None:
Errors.exit_with_error(logger, message=_("errors.xmlapi.not_found"))
return matching_custom_view
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import os

from uuid import UUID

from tabcmd.commands.constants import Errors
from tabcmd.commands.datasources_and_workbooks.datasources_and_workbooks_command import DatasourcesAndWorkbooks
from tabcmd.commands.server import Server
from tabcmd.execution.localize import _


class DatasourcesWorkbooksAndViewsUrlParser(Server):
"""
Base Class for parsing & fetching Datasources, Workbooks, Views & Custom Views information from get/export URLs
"""

def __init__(self, args):
super().__init__(args)

@staticmethod
def get_view_url_from_names(wb_name, view_name):
return "{}/sheets/{}".format(wb_name, view_name)

@staticmethod
def parse_export_url_to_workbook_view_and_custom_view(logger, url):
# input should be workbook_name/view_name or /workbook_name/view_name
# or workbook_name/view_name/custom_view_id/custom_view_name
name_parts = DatasourcesWorkbooksAndViewsUrlParser.validate_and_extract_url_parts(logger, url)
if len(name_parts) == 2:
workbook = name_parts[0]
view = DatasourcesWorkbooksAndViewsUrlParser.get_view_url_from_names(workbook, name_parts[1])
return view, workbook, None, None
elif len(name_parts) == 4:
workbook = name_parts[0]
view = DatasourcesWorkbooksAndViewsUrlParser.get_view_url_from_names(workbook, name_parts[1])
custom_view_id = name_parts[2]
DatasourcesWorkbooksAndViewsUrlParser.verify_valid_custom_view_id(logger, custom_view_id)
custom_view_name = name_parts[3]
return view, workbook, custom_view_id, custom_view_name
else:
return None, None, None, None

@staticmethod
def validate_and_extract_url_parts(logger, url):
logger.info(_("export.status").format(url))
if " " in url:
Errors.exit_with_error(logger, _("export.errors.white_space_workbook_view"))
if "?" in url:
url = url.split("?")[0]
url = url.lstrip("/") # strip opening / if present
return url.split("/")

@staticmethod
def get_export_item_and_server_content_type_from_export_url(view_content_url, logger, server, custom_view_id):
return DatasourcesWorkbooksAndViewsUrlParser.get_content_and_server_content_type_from_url(
logger, server, view_content_url, custom_view_id
)

################### GetURL Methods ##############################

@staticmethod
def explain_expected_get_url(logger, url: str, command: str):
view_example = "/views/<workbookname>/<viewname>[.ext]"
custom_view_example = "/views/<workbookname>/<viewname>/<customviewid>/<customviewname>[.ext]"
wb_example = "/workbooks/<workbookname>[.ext]"
ds_example = "/datasources/<datasourcename[.ext]"
message = _("export.errors.requires_workbook_view_param").format(
command
) + "Given: {0}. Accepted values: {1}, {2}, {3}, {4}".format(
url, view_example, custom_view_example, wb_example, ds_example
)
Errors.exit_with_error(logger, message)

@staticmethod
def get_file_type_from_filename(logger, url, file_name):
logger.debug("Choosing between {}, {}".format(file_name, url))
file_name = file_name or url
logger.debug(_("get.options.file") + ": {}".format(file_name)) # Name to save the file as
type_of_file = DatasourcesWorkbooksAndViewsUrlParser.get_file_extension(file_name)

if not type_of_file and file_name is not None:
# check the url
backup = DatasourcesWorkbooksAndViewsUrlParser.get_file_extension(url)
if backup is not None:
type_of_file = backup
else:
Errors.exit_with_error(logger, _("get.extension.not_found").format(file_name))

logger.debug("filetype: {}".format(type_of_file))
if type_of_file in ["pdf", "csv", "png", "twb", "twbx", "tdsx", "tds"]:
return type_of_file

Errors.exit_with_error(logger, _("get.extension.not_found").format(file_name))

@staticmethod
def get_file_extension(path):
path_segments = os.path.split(path)
filename = path_segments[-1]
filename_segments = filename.split(".")
extension = filename_segments[-1]
extension = DatasourcesWorkbooksAndViewsUrlParser.strip_query_params(extension)
return extension

@staticmethod
def strip_query_params(filename):
if "?" in filename:
return filename.split("?")[0]
else:
return filename

@staticmethod
def get_name_without_possible_extension(filename):
return filename.split(".")[0]

@staticmethod
def get_resource_name(url: str, logger): # workbooks/wb-name" -> "wb-name", datasource/ds-name -> ds-name
url = url.lstrip("/") # strip opening / if present
name_parts = url.split("/")
if len(name_parts) != 2:
DatasourcesWorkbooksAndViewsUrlParser.explain_expected_get_url(logger, url, "GetUrl")
resource_name_with_params = name_parts[::-1][0] # last part
resource_name_with_ext = DatasourcesWorkbooksAndViewsUrlParser.strip_query_params(resource_name_with_params)
resource_name = DatasourcesWorkbooksAndViewsUrlParser.get_name_without_possible_extension(
resource_name_with_ext
)
return resource_name

@staticmethod
def get_view_url_from_get_url(logger, url): # "views/wb-name/view-name" -> wb-name/sheets/view-name
name_parts = url.split("/") # ['views', 'wb-name', 'view-name']
if len(name_parts) != 3:
DatasourcesWorkbooksAndViewsUrlParser.explain_expected_get_url(logger, url, "GetUrl")
workbook_name = name_parts[1]
view_name = name_parts[::-1][0]
view_name = DatasourcesWorkbooksAndViewsUrlParser.strip_query_params(view_name)
view_name = DatasourcesWorkbooksAndViewsUrlParser.get_name_without_possible_extension(view_name)
return DatasourcesWorkbooksAndViewsUrlParser.get_view_url_from_names(workbook_name, view_name)

@staticmethod
def get_custom_view_parts_from_get_url(logger, url):
name_parts = url.split("/") # ['views', 'wb-name', 'view-name', 'custom-view-id', 'custom-view-name']
if len(name_parts) != 5:
DatasourcesWorkbooksAndViewsUrlParser.explain_expected_get_url(logger, url, "GetUrl")
workbook_name = name_parts[1]
view_name = name_parts[2]
custom_view_id = name_parts[3]
DatasourcesWorkbooksAndViewsUrlParser.verify_valid_custom_view_id(logger, custom_view_id)
custom_view_name = name_parts[::-1][0]
custom_view_name = DatasourcesWorkbooksAndViewsUrlParser.strip_query_params(custom_view_name)
custom_view_name = DatasourcesWorkbooksAndViewsUrlParser.get_name_without_possible_extension(custom_view_name)
return (
DatasourcesWorkbooksAndViewsUrlParser.get_view_url_from_names(workbook_name, view_name),
custom_view_id,
custom_view_name,
)

@staticmethod
def parse_get_view_url_to_view_and_custom_view_parts(logger, url):
# input should be views/workbook_name/view_name
# or views/workbook_name/view_name/custom_view_id/custom_view_name
name_parts = DatasourcesWorkbooksAndViewsUrlParser.validate_and_extract_url_parts(logger, url)
if len(name_parts) == 3:
return DatasourcesWorkbooksAndViewsUrlParser.get_view_url_from_get_url(logger, url), None, None
elif len(name_parts) == 5:
return DatasourcesWorkbooksAndViewsUrlParser.get_custom_view_parts_from_get_url(logger, url)
else:
DatasourcesWorkbooksAndViewsUrlParser.explain_expected_get_url(logger, url, "GetUrl")

@staticmethod
def get_url_item_and_item_type_from_view_url(logger, url, server):
(
view_url,
custom_view_id,
custom_view_name,
) = DatasourcesWorkbooksAndViewsUrlParser.parse_get_view_url_to_view_and_custom_view_parts(logger, url)

return DatasourcesWorkbooksAndViewsUrlParser.get_content_and_server_content_type_from_url(
logger, server, view_url, custom_view_id
)

@staticmethod
def get_content_and_server_content_type_from_url(logger, server, view_content_url, custom_view_id):
item = DatasourcesAndWorkbooks.get_view_by_content_url(logger, server, view_content_url)
server_content_type = server.views

if custom_view_id:
custom_view_item = DatasourcesAndWorkbooks.get_custom_view_by_id(logger, server, custom_view_id)
if custom_view_item.view.id != item.id:
Errors.exit_with_error(logger, "Invalid custom view URL provided")
server_content_type = server.custom_views
item = custom_view_item
return item, server_content_type

@staticmethod
def verify_valid_custom_view_id(logger, custom_view_id):
try:
UUID(custom_view_id)
except ValueError:
Errors.exit_with_error(logger, _("export.errors.requires_valid_custom_view_uuid"))
68 changes: 31 additions & 37 deletions tabcmd/commands/datasources_and_workbooks/export_command.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import tableauserverclient as TSC

from uuid import UUID

from tabcmd.commands.auth.session import Session
from tabcmd.commands.constants import Errors
from tabcmd.execution.localize import _
from tabcmd.execution.logger_config import log
from .datasources_and_workbooks_command import DatasourcesAndWorkbooks
from .datasources_workbooks_views_url_parser import DatasourcesWorkbooksAndViewsUrlParser

pagesize = TSC.PDFRequestOptions.PageType # type alias for brevity

Expand Down Expand Up @@ -76,7 +79,12 @@ def run_command(args):
logger.debug(_("tabcmd.launching"))
session = Session()
server = session.create_session(args, logger)
view_content_url, wb_content_url = ExportCommand.parse_export_url_to_workbook_and_view(logger, args.url)
(
view_content_url,
wb_content_url,
custom_view_id,
custom_view_name,
) = DatasourcesWorkbooksAndViewsUrlParser.parse_export_url_to_workbook_view_and_custom_view(logger, args.url)
logger.debug(["view_url:", view_content_url, "workbook:", wb_content_url])
if not view_content_url and not wb_content_url:
view_example = "/workbook_name/view_name"
Expand All @@ -92,19 +100,23 @@ def run_command(args):

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)
elif args.pdf or args.png or args.csv: # it's a view or custom_view
(
export_item,
server_content_type,
) = DatasourcesWorkbooksAndViewsUrlParser.get_export_item_and_server_content_type_from_export_url(
view_content_url, logger, server, custom_view_id
)

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

default_filename = "{}.png".format(view_item.name)
output = ExportCommand.download_png(server_content_type, export_item, args, logger)
default_filename = "{}.png".format(export_item.name)

except TSC.ServerResponseError as e:
Errors.exit_with_error(logger, _("publish.errors.unexpected_server_response").format(""), e)
Expand Down Expand Up @@ -142,51 +154,33 @@ def download_wb_pdf(server, workbook_item, args, logger):
return workbook_item.pdf

@staticmethod
def download_view_pdf(server, view_item, args, logger):
def download_view_pdf(server_content_type, export_item, args, logger):
logger.debug(args.url)
pdf_options = TSC.PDFRequestOptions(maxage=1)
ExportCommand.apply_values_from_url_params(logger, pdf_options, args.url)
ExportCommand.apply_filters_from_args(pdf_options, args, logger)
ExportCommand.apply_pdf_options(logger, pdf_options, args)
logger.debug(pdf_options.get_query_params())
server.views.populate_pdf(view_item, pdf_options)
return view_item.pdf
server_content_type.populate_pdf(export_item, pdf_options)
return export_item.pdf

@staticmethod
def download_csv(server, view_item, args, logger):
def download_csv(server_content_type, export_item, args, logger):
logger.debug(args.url)
csv_options = TSC.CSVRequestOptions(maxage=1)
ExportCommand.apply_values_from_url_params(logger, csv_options, args.url)
ExportCommand.apply_filters_from_args(csv_options, args, logger)
logger.debug(csv_options.get_query_params())
server.views.populate_csv(view_item, csv_options)
return view_item.csv
server_content_type.populate_csv(export_item, csv_options)
return export_item.csv

@staticmethod
def download_png(server, view_item, args, logger):
def download_png(server_content_type, export_item, args, logger):
logger.debug(args.url)
image_options = TSC.ImageRequestOptions(maxage=1)
ExportCommand.apply_values_from_url_params(logger, image_options, args.url)
ExportCommand.apply_filters_from_args(image_options, args, logger)
DatasourcesAndWorkbooks.apply_png_options(logger, image_options, args)
logger.debug(image_options.get_query_params())
server.views.populate_image(view_item, image_options)
return view_item.image

@staticmethod
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"))
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("/")
if len(name_parts) != 2:
return None, None
workbook = name_parts[0]
view = "{}/sheets/{}".format(workbook, name_parts[1])
return view, workbook
server_content_type.populate_image(export_item, image_options)
return export_item.image
Loading

0 comments on commit e2a6879

Please sign in to comment.