Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding support for custom view urls to export & get tabcmd commands #313

Merged
merged 7 commits into from
Nov 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/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