Repository: tableau/tabcmd (language: python)

Issue:
Auto-trigger build and upload binaries when a release is created
Tested in another repo: https://github.com/jacalata/tabcmd/actions


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/generate-metadata.yml b/.github/workflows/generate-metadata.yml
index b46f4793..2e46c5b5 100644
--- a/.github/workflows/generate-metadata.yml
+++ b/.github/workflows/generate-metadata.yml
@@ -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: |
diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml
index 5e1ffdc0..d9be64c3 100644
--- a/.github/workflows/package.yml
+++ b/.github/workflows/package.yml
@@ -9,8 +9,9 @@ name: Release-Executable
 # https://anshumanfauzdar.medium.com/using-github-actions-to-bundle-python-application-into-a-single-package-and-automatic-release-834bd42e0670
 
 on:
-  release:
-    types: [published]
+  push:
+    tags:
+      - '*'
   workflow_dispatch:
 
 jobs:
@@ -58,7 +59,7 @@ jobs:
 
     - uses: actions/setup-python@v5
       with:
-        python-version: 3.8
+        python-version: 3.9
 
     - name: Install dependencies and build
       run: |
@@ -94,10 +95,20 @@ jobs:
       with:
         name: ${{ matrix.OUT_FILE_NAME }}
         path: ./dist/${{ matrix.TARGET }}/${{ matrix.OUT_FILE_NAME }}
-
+        
     - name: Upload artifact for Mac 
       if: matrix.TARGET == 'macos'
       uses: actions/upload-artifact@v4
       with:
         name: ${{ matrix.BUNDLE_NAME }}
         path: ./dist/${{ matrix.TARGET }}/${{ matrix.BUNDLE_NAME }}.tar
+    
+    - name: Upload binaries to release
+      uses: svenstaro/upload-release-action@v2
+      with:
+        repo_token: ${{ secrets.GITHUB_TOKEN }}
+        file: ./dist/${{ matrix.TARGET }}/${{ matrix.OUT_FILE_NAME }}/
+        tag: ${{ github.ref_name }}
+        overwrite: true
+        promote: true
+
diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml
index bbf766af..d08307ff 100644
--- a/.github/workflows/publish-pypi.yml
+++ b/.github/workflows/publish-pypi.yml
@@ -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
diff --git a/pyproject.toml b/pyproject.toml
index 00a6ed92..a8839e58 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -52,7 +52,7 @@ dependencies = [
     "types-mock",
     "types-requests",
     "types-setuptools",
-    "tableauserverclient==0.31",
+    "tableauserverclient==0.34",
     "urllib3",
 ]
 [project.optional-dependencies]
diff --git a/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py b/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py
index a10e4029..5a9cfe17 100644
--- a/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py
+++ b/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py
@@ -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
diff --git a/tabcmd/commands/datasources_and_workbooks/datasources_workbooks_views_url_parser.py b/tabcmd/commands/datasources_and_workbooks/datasources_workbooks_views_url_parser.py
new file mode 100644
index 00000000..e5d3e4db
--- /dev/null
+++ b/tabcmd/commands/datasources_and_workbooks/datasources_workbooks_views_url_parser.py
@@ -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"))
diff --git a/tabcmd/commands/datasources_and_workbooks/export_command.py b/tabcmd/commands/datasources_and_workbooks/export_command.py
index 23012b51..5781c137 100644
--- a/tabcmd/commands/datasources_and_workbooks/export_command.py
+++ b/tabcmd/commands/datasources_and_workbooks/export_command.py
@@ -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
 
@@ -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"
@@ -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)
@@ -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
diff --git a/tabcmd/commands/datasources_and_workbooks/get_url_command.py b/tabcmd/commands/datasources_and_workbooks/get_url_command.py
index 9f84c242..05e52a8f 100644
--- a/tabcmd/commands/datasources_and_workbooks/get_url_command.py
+++ b/tabcmd/commands/datasources_and_workbooks/get_url_command.py
@@ -1,8 +1,6 @@
 import inspect
-import os
 
 import tableauserverclient as TSC
-from tableauserverclient import ServerResponseError
 
 from tabcmd.commands.auth.session import Session
 from tabcmd.commands.constants import Errors
@@ -10,6 +8,7 @@
 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
 
 
 class GetUrl(DatasourcesAndWorkbooks):
@@ -48,7 +47,7 @@ def run_command(args):
 
         url = args.url.lstrip("/")  # strip opening / if present
         content_type = GetUrl.evaluate_content_type(logger, url)
-        file_type = GetUrl.get_file_type_from_filename(logger, url, args.filename)
+        file_type = DatasourcesWorkbooksAndViewsUrlParser.get_file_type_from_filename(logger, url, args.filename)
 
         GetUrl.get_content_as_file(file_type, content_type, logger, args, server, url)
 
@@ -65,79 +64,6 @@ def evaluate_content_type(logger, url):
                 return content_type
         Errors.exit_with_error(logger, message=_("bad_request.detail.invalid_content_type").format(url))
 
-    @staticmethod
-    def explain_expected_url(logger, url: str, command: str):
-        view_example = "/views/<workbookname>/<viewname>[.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}".format(url, 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 = GetUrl.get_file_extension(file_name)
-
-        if not type_of_file and file_name is not None:
-            # check the url
-            backup = GetUrl.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 = GetUrl.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:
-            GetUrl.explain_expected_url(logger, url, "GetUrl")
-        resource_name_with_params = name_parts[::-1][0]  # last part
-        resource_name_with_ext = GetUrl.strip_query_params(resource_name_with_params)
-        resource_name = GetUrl.get_name_without_possible_extension(resource_name_with_ext)
-        return resource_name
-
-    @staticmethod
-    def get_view_url(url, logger):  # "views/wb-name/view-name" -> wb-name/sheets/view-name
-        name_parts = url.split("/")  # ['views', 'wb-name', 'view-name']
-        if len(name_parts) != 3:
-            GetUrl.explain_expected_url(logger, url, "GetUrl")
-        workbook_name = name_parts[1]
-        view_name = name_parts[::-1][0]
-        view_name = GetUrl.strip_query_params(view_name)
-        view_name = GetUrl.get_name_without_possible_extension(view_name)
-        return DatasourcesAndWorkbooks.get_view_url_from_names(workbook_name, view_name)
-
     @staticmethod
     def filename_from_args(file_argument, item_name, filetype):
         if file_argument is None:
@@ -157,71 +83,74 @@ def get_content_as_file(file_type, content_type, logger, args, server, url):
         elif content_type == "datasource":
             return GetUrl.generate_tds(logger, server, args, file_type)
         elif content_type == "view":
-            view_url = GetUrl.get_view_url(url, logger)
+            (
+                get_url_item,
+                server_content_type,
+            ) = DatasourcesWorkbooksAndViewsUrlParser.get_url_item_and_item_type_from_view_url(logger, url, server)
+
             if file_type == "pdf":
-                return GetUrl.generate_pdf(logger, server, args, view_url)
+                return GetUrl.generate_pdf(logger, server_content_type, args, get_url_item)
             elif file_type == "png":
-                return GetUrl.generate_png(logger, server, args, view_url)
+                return GetUrl.generate_png(logger, server_content_type, args, get_url_item)
             elif file_type == "csv":
-                return GetUrl.generate_csv(logger, server, args, view_url)
+                return GetUrl.generate_csv(logger, server_content_type, args, get_url_item)
         # all the known options above will return early. If we get here we are confused.
         Errors.exit_with_error(logger, message=_("get.extension.not_found"))
 
     @staticmethod
-    def generate_pdf(logger, server, args, view_url):
+    def generate_pdf(logger, server_content_type, args, get_url_item):
         logger.trace("Entered method " + inspect.stack()[0].function)
         try:
-            view_item: TSC.ViewItem = GetUrl.get_view_by_content_url(logger, server, view_url)
-            logger.debug(_("content_type.view") + ": {}".format(view_item.name))
+            logger.debug(_("content_type.view") + ": {}".format(get_url_item.name))
             req_option_pdf = TSC.PDFRequestOptions(maxage=1)
             DatasourcesAndWorkbooks.apply_values_from_url_params(logger, req_option_pdf, args.url)
-            server.views.populate_pdf(view_item, req_option_pdf)
-            filename = GetUrl.filename_from_args(args.filename, view_item.name, "pdf")
-            DatasourcesAndWorkbooks.save_to_file(logger, view_item.pdf, filename)
+            server_content_type.populate_pdf(get_url_item, req_option_pdf)
+            filename = GetUrl.filename_from_args(args.filename, get_url_item.name, "pdf")
+            DatasourcesAndWorkbooks.save_to_file(logger, get_url_item.pdf, filename)
         except Exception as e:
             Errors.exit_with_error(logger, exception=e)
 
     @staticmethod
-    def generate_png(logger, server, args, view_url):
+    def generate_png(logger, server_content_type, args, get_url_item):
         logger.trace("Entered method " + inspect.stack()[0].function)
         try:
-            view_item: TSC.ViewItem = GetUrl.get_view_by_content_url(logger, server, view_url)
-            logger.debug(_("content_type.view") + ": {}".format(view_item.name))
+            logger.debug(_("content_type.view") + ": {}".format(get_url_item.name))
             req_option_csv = TSC.ImageRequestOptions(maxage=1)
             DatasourcesAndWorkbooks.apply_values_from_url_params(logger, req_option_csv, args.url)
-            server.views.populate_image(view_item, req_option_csv)
-            filename = GetUrl.filename_from_args(args.filename, view_item.name, "png")
-            DatasourcesAndWorkbooks.save_to_file(logger, view_item.image, filename)
+            server_content_type.populate_image(get_url_item, req_option_csv)
+            filename = GetUrl.filename_from_args(args.filename, get_url_item.name, "png")
+            DatasourcesAndWorkbooks.save_to_file(logger, get_url_item.image, filename)
         except Exception as e:
             Errors.exit_with_error(logger, exception=e)
 
     @staticmethod
-    def generate_csv(logger, server, args, view_url):
+    def generate_csv(logger, server_content_type, args, get_url_item):
         logger.trace("Entered method " + inspect.stack()[0].function)
         try:
-            view_item: TSC.ViewItem = GetUrl.get_view_by_content_url(logger, server, view_url)
-            logger.debug(_("content_type.view") + ": {}".format(view_item.name))
+            logger.debug(_("content_type.view") + ": {}".format(get_url_item.name))
             req_option_csv = TSC.CSVRequestOptions(maxage=1)
             DatasourcesAndWorkbooks.apply_values_from_url_params(logger, req_option_csv, args.url)
-            server.views.populate_csv(view_item, req_option_csv)
-            file_name_with_path = GetUrl.filename_from_args(args.filename, view_item.name, "csv")
-            DatasourcesAndWorkbooks.save_to_data_file(logger, view_item.csv, file_name_with_path)
+            server_content_type.populate_csv(get_url_item, req_option_csv)
+            file_name_with_path = GetUrl.filename_from_args(args.filename, get_url_item.name, "csv")
+            DatasourcesAndWorkbooks.save_to_data_file(logger, get_url_item.csv, file_name_with_path)
         except Exception as e:
             Errors.exit_with_error(logger, exception=e)
 
     @staticmethod
     def generate_twb(logger, server, args, file_extension, url):
         logger.trace("Entered method " + inspect.stack()[0].function)
-        workbook_name = GetUrl.get_resource_name(url, logger)
+        workbook_name = DatasourcesWorkbooksAndViewsUrlParser.get_resource_name(url, logger)
         try:
             target_workbook = GetUrl.get_wb_by_content_url(logger, server, workbook_name)
             logger.debug(_("content_type.workbook") + ": {}".format(workbook_name))
             file_name_with_path = GetUrl.filename_from_args(args.filename, workbook_name, file_extension)
             # the download method will add an extension. How do I tell which one?
-            file_name_with_path = GetUrl.get_name_without_possible_extension(file_name_with_path)
+            file_name_with_path = DatasourcesWorkbooksAndViewsUrlParser.get_name_without_possible_extension(
+                file_name_with_path
+            )
             file_name_with_ext = "{}.{}".format(file_name_with_path, file_extension)
             logger.debug("Saving as {}".format(file_name_with_ext))
-            server.workbooks.download(target_workbook.id, filepath=file_name_with_path, no_extract=False)
+            server.workbooks.download(target_workbook.id, filepath=file_name_with_path, include_extract=True)
             logger.info(_("export.success").format(target_workbook.name, file_name_with_ext))
         except Exception as e:
             Errors.exit_with_error(logger, exception=e)
@@ -229,16 +158,18 @@ def generate_twb(logger, server, args, file_extension, url):
     @staticmethod
     def generate_tds(logger, server, args, file_extension):
         logger.trace("Entered method " + inspect.stack()[0].function)
-        datasource_name = GetUrl.get_resource_name(args.url, logger)
+        datasource_name = DatasourcesWorkbooksAndViewsUrlParser.get_resource_name(args.url, logger)
         try:
             target_datasource = GetUrl.get_ds_by_content_url(logger, server, datasource_name)
             logger.debug(_("content_type.datasource") + ": {}".format(datasource_name))
             file_name_with_path = GetUrl.filename_from_args(args.filename, datasource_name, file_extension)
             # the download method will add an extension
-            file_name_with_path = GetUrl.get_name_without_possible_extension(file_name_with_path)
+            file_name_with_path = DatasourcesWorkbooksAndViewsUrlParser.get_name_without_possible_extension(
+                file_name_with_path
+            )
             file_name_with_ext = "{}.{}".format(file_name_with_path, file_extension)
             logger.debug("Saving as {}".format(file_name_with_ext))
-            server.datasources.download(target_datasource.id, filepath=file_name_with_path, no_extract=False)
+            server.datasources.download(target_datasource.id, filepath=file_name_with_path, include_extract=True)
             logger.info(_("export.success").format(target_datasource.name, file_name_with_ext))
         except Exception as e:
             Errors.exit_with_error(logger, exception=e)
diff --git a/tabcmd/commands/server.py b/tabcmd/commands/server.py
index d315e6dd..4091c0ba 100644
--- a/tabcmd/commands/server.py
+++ b/tabcmd/commands/server.py
@@ -55,7 +55,7 @@ def get_items_by_name(logger, item_endpoint, item_name: str, container: Optional
         all_items, pagination_item = item_endpoint.get(req_option)
         if all_items is None or all_items == []:
             raise TSC.ServerResponseError(
-                code=404,
+                code="404",
                 summary=_("errors.xmlapi.not_found"),
                 detail=_("errors.xmlapi.not_found") + ": " + item_log_name,
             )
diff --git a/tabcmd/locales/de/tabcmd_messages_de.properties b/tabcmd/locales/de/tabcmd_messages_de.properties
index 45ca19b6..d43cbf80 100644
--- a/tabcmd/locales/de/tabcmd_messages_de.properties
+++ b/tabcmd/locales/de/tabcmd_messages_de.properties
@@ -165,6 +165,7 @@ export.errors.need_country_and_languge=Die Optionen --Land und --Sprache müssen
 export.errors.white_space_workbook_view=Der Name der zu exportierenden Arbeitsmappe oder Ansicht darf keine Leerzeichen enthalten. Geben Sie den normalisierten Namen der Arbeitsmappe oder Ansicht an, wie er in der URL angezeigt wird.
 export.errors.requires_workbook_view_name=Für den Befehl „{0}“ ist ein <workbook>/<view>-Name erforderlich.
 export.errors.requires_workbook_view_param=Für den Befehl „{0}“ ist ein <workbook>/<view>-Parameter erforderlich, und es muss mindestens ein Schrägstrich (/) in diesem Parameter vorhanden sein.
+export.errors.requires_valid_custom_view_uuid=The URL for custom views must contain a valid custom view uuid
 export.options.country=Wenn das standardmäßige Gebietsschema des Benutzers nicht verwendet wird, muss die Landesabkürzung für Gebietsschema (zu finden in der IANA Language Subtag Registry) mit --Sprache verwendet werden.
 export.options.csv=Daten im CSV-Format (Standard) exportieren
 export.options.fullpdf=Visuelle Ansichten im PDF-Format (wenn die Arbeitsmappe mit Registerkarten veröffentlicht wurde) exportieren
diff --git a/tabcmd/locales/en/tabcmd_messages_en.properties b/tabcmd/locales/en/tabcmd_messages_en.properties
index 05e20f34..ee0a1e1a 100644
--- a/tabcmd/locales/en/tabcmd_messages_en.properties
+++ b/tabcmd/locales/en/tabcmd_messages_en.properties
@@ -165,6 +165,7 @@ export.errors.need_country_and_languge=The options --country and --language must
 export.errors.white_space_workbook_view=The name of the workbook or view to export cannot include spaces. Use the normalized name of the workbook or view as it appears in the URL.
 export.errors.requires_workbook_view_name=The ''{0}'' command requires a <workbook>/<view> name
 export.errors.requires_workbook_view_param=The ''{0}'' command requires a <workbook>/<view> parameter, and there must be at least one slash (/) in this parameter
+export.errors.requires_valid_custom_view_uuid=The URL for custom views must contain a valid custom view uuid
 export.options.country=If not using user''s default locale, the country abbreviation for locale (find in IANA Language Subtag Registry). Must use with --language
 export.options.csv=Export data in CSV format (default)
 export.options.fullpdf=Export visual views in PDF format (if workbook was published with tabs)
diff --git a/tabcmd/locales/es/tabcmd_messages_es.properties b/tabcmd/locales/es/tabcmd_messages_es.properties
index c77c2082..623799bb 100644
--- a/tabcmd/locales/es/tabcmd_messages_es.properties
+++ b/tabcmd/locales/es/tabcmd_messages_es.properties
@@ -165,6 +165,7 @@ export.errors.need_country_and_languge=Las opciones --country y --language se de
 export.errors.white_space_workbook_view=El nombre del libro de trabajo o la vista de la exportación no puede incluir espacios. Use el nombre normalizado del libro de trabajo o la vista tal como aparece en la URL.
 export.errors.requires_workbook_view_name=El comando “{0}” requiere un nombre de <workbook>/<view>
 export.errors.requires_workbook_view_param=El comando “{0}” requiere un parámetro de <workbook>/<view>, que debe contener al menos una barra (/)
+export.errors.requires_valid_custom_view_uuid=The URL for custom views must contain a valid custom view uuid
 export.options.country=Si no se utiliza la configuración regional predeterminada del usuario, se debe usar la abreviatura de la configuración regional del país (que se puede encontrar en el registro de subcategorías de idioma de la IANA). con el comando --language
 export.options.csv=Exportar datos en formato CSV (predeterminado)
 export.options.fullpdf=Exportar las vistas visuales en formato PDF (si el libro de trabajo se ha publicado con pestañas)
diff --git a/tabcmd/locales/fr/tabcmd_messages_fr.properties b/tabcmd/locales/fr/tabcmd_messages_fr.properties
index 854510f9..bd88a646 100644
--- a/tabcmd/locales/fr/tabcmd_messages_fr.properties
+++ b/tabcmd/locales/fr/tabcmd_messages_fr.properties
@@ -165,6 +165,7 @@ export.errors.need_country_and_languge=Les options --country et --language doive
 export.errors.white_space_workbook_view=Le nom du classeur ou de la vue à exporter ne peut pas inclure d’espaces. Utilisez le nom normalisé du classeur ou de la vue tel qu’il apparaît dans l’URL.
 export.errors.requires_workbook_view_name=La commande “{0}” nécessite un nom de <workbook>/<view>
 export.errors.requires_workbook_view_param=La commande “{0}” nécessite un paramètre de <workbook>/<view> contenant au moins une barre oblique (/)
+export.errors.requires_valid_custom_view_uuid=The URL for custom views must contain a valid custom view uuid
 export.options.country=Si les paramètres régionaux par défaut de l’utilisateur sont utilisés, abréviation du pays pour les paramètres régionaux (se trouve dans IANA Language Subtag Registry). À utiliser avec --language
 export.options.csv=Exporter des données dans le format CSV (par défaut)
 export.options.fullpdf=Exporter des vues dans le format PDF (si le classeur a été publié avec des onglets)
diff --git a/tabcmd/locales/ga/tabcmd_messages_ga.properties b/tabcmd/locales/ga/tabcmd_messages_ga.properties
index 776c45d1..ce4b6193 100644
--- a/tabcmd/locales/ga/tabcmd_messages_ga.properties
+++ b/tabcmd/locales/ga/tabcmd_messages_ga.properties
@@ -165,6 +165,7 @@ export.errors.need_country_and_languge=bf52-表:The options --country and --lang
 export.errors.white_space_workbook_view=e1f6-表:The name of the workbook or view to export cannot include spaces. Use the normalized name of the workbook or view as it appears in the URL.|桜
 export.errors.requires_workbook_view_name=cafc-表:The ‘{0}’ command requires a <workbook>/<view> name|桜
 export.errors.requires_workbook_view_param=dbd2-表:The ‘{0}’ command requires a <workbook>/<view> parameter, and there must be at least one slash (/) in this parameter|桜
+export.errors.requires_valid_custom_view_uuid=The URL for custom views must contain a valid custom view uuid
 export.options.country=33e3-表:If not using user’s default locale, the country abbreviation for locale (find in IANA Language Subtag Registry). Must use with --language|桜
 export.options.csv=4e37-表:Export data in CSV format (default)|桜
 export.options.fullpdf=389e-表:Export visual views in PDF format (if workbook was published with tabs)|桜
diff --git a/tabcmd/locales/it/tabcmd_messages_it.properties b/tabcmd/locales/it/tabcmd_messages_it.properties
index 447056f9..34e5d247 100644
--- a/tabcmd/locales/it/tabcmd_messages_it.properties
+++ b/tabcmd/locales/it/tabcmd_messages_it.properties
@@ -165,6 +165,7 @@ export.errors.need_country_and_languge=Le opzioni --country e --language devono
 export.errors.white_space_workbook_view=Il nome della cartella di lavoro o della vista da esportare non può includere spazi. Utilizza il nome normalizzato della cartella di lavoro o della vista come appare nell’URL.
 export.errors.requires_workbook_view_name=Il comando “{0}” richiede un nome <workbook>/<view>
 export.errors.requires_workbook_view_param=Il comando “{0}” richiede un parametro <workbook>/<view> e ci deve essere almeno una barra (/) in questo parametro
+export.errors.requires_valid_custom_view_uuid=The URL for custom views must contain a valid custom view uuid
 export.options.country=Se non vengono utilizzate le impostazioni locali predefinite dell’utente, l’abbreviazione del paese per le impostazioni locali (disponibile nel registro dei sottotag delle lingue IANA). Da utilizzare con --language
 export.options.csv=Esporta i dati in formato CSV (predefinito)
 export.options.fullpdf=Esporta viste visive in formato PDF (se la cartella di lavoro è stata pubblicata con schede)
diff --git a/tabcmd/locales/ja/tabcmd_messages_ja.properties b/tabcmd/locales/ja/tabcmd_messages_ja.properties
index e59ff9c1..17853b75 100644
--- a/tabcmd/locales/ja/tabcmd_messages_ja.properties
+++ b/tabcmd/locales/ja/tabcmd_messages_ja.properties
@@ -165,6 +165,7 @@ export.errors.need_country_and_languge=オプションの「--country」と「--
 export.errors.white_space_workbook_view=エクスポートするワークブックやビューの名前にスペースを含めることはできません。URL に表示されるワークブックまたはビューの標準化された名前を使用してください。
 export.errors.requires_workbook_view_name=「{0}」 コマンドには <workbook>/<view> 名が必要です
 export.errors.requires_workbook_view_param=「{0}」 コマンドには <workbook>/<view> パラメーターが必要です。このパラメーターには、少なくとも 1 個のスラッシュ (/) が必要です
+export.errors.requires_valid_custom_view_uuid=The URL for custom views must contain a valid custom view uuid
 export.options.country=既定のユーザー ロケールを使用しない場合は、ロケールの国の略名 (IANA Language Subtag Registry から検索) を使用します。「--language」とセットで使用する必要があります
 export.options.csv=データを CSV 形式でエクスポート (既定)
 export.options.fullpdf=ビジュアル ビューを PDF 形式でエクスポート (ワークブックがタブ付きでパブリッシュされた場合)
diff --git a/tabcmd/locales/ko/tabcmd_messages_ko.properties b/tabcmd/locales/ko/tabcmd_messages_ko.properties
index 4f1ab1ec..04c5b757 100644
--- a/tabcmd/locales/ko/tabcmd_messages_ko.properties
+++ b/tabcmd/locales/ko/tabcmd_messages_ko.properties
@@ -165,6 +165,7 @@ export.errors.need_country_and_languge=--country 옵션과 --language 옵션은
 export.errors.white_space_workbook_view=내보낼 통합 문서 또는 뷰의 이름에 공백을 포함할 수 없습니다. 통합 문서 또는 뷰의 정규화된 이름을 URL에 표시되는 대로 사용하십시오.
 export.errors.requires_workbook_view_name=’{0}’ 명령에는 <workbook>/<view> 이름이 필요합니다.
 export.errors.requires_workbook_view_param=’{0}’ 명령에는 <workbook>/<view> 매개 변수가 필요하고, 이 매개 변수에는 하나 이상의 슬래시(/)가 있어야 합니다.
+export.errors.requires_valid_custom_view_uuid=The URL for custom views must contain a valid custom view uuid
 export.options.country=사용자의 기본 로캘을 사용하지 않는 경우 로캘의 국가 약어입니다(IANA Language Subtag Registry에서 찾을 수 있음). --language와 함께 사용해야 합니다.
 export.options.csv=CSV 형식으로 데이터 내보내기(기본값)
 export.options.fullpdf=PDF 형식으로 시각적 뷰 내보내기(통합 문서가 탭과 함께 게시된 경우)
diff --git a/tabcmd/locales/pt/tabcmd_messages_pt.properties b/tabcmd/locales/pt/tabcmd_messages_pt.properties
index 59ae77f2..59322906 100644
--- a/tabcmd/locales/pt/tabcmd_messages_pt.properties
+++ b/tabcmd/locales/pt/tabcmd_messages_pt.properties
@@ -165,6 +165,7 @@ export.errors.need_country_and_languge=As opções --país e --idioma devem ser
 export.errors.white_space_workbook_view=O nome da pasta de trabalho ou da exibição a ser exportada não pode incluir espaços. Use o nome normalizado da pasta de trabalho ou da exibição, conforme aparece na URL.
 export.errors.requires_workbook_view_name=O comando “{0}” requer um nome <workbook>/<view>
 export.errors.requires_workbook_view_param=O comando “{0}” requer um parâmetro <workbook>/<view>, e deve haver pelo menos uma barra (/) neste parâmetro
+export.errors.requires_valid_custom_view_uuid=The URL for custom views must contain a valid custom view uuid
 export.options.country=Se não estiver usando a localidade padrão do usuário, a abreviação de país da localidade (encontre no Registro de marca secundária de idioma IANA). Deve usar com o --idioma
 export.options.csv=Exporte dados em formato CSV (padrão)
 export.options.fullpdf=Exporte exibições visuais em formato PDF (se a pasta de trabalho foi publicada com guias)
diff --git a/tabcmd/locales/sv/tabcmd_messages_sv.properties b/tabcmd/locales/sv/tabcmd_messages_sv.properties
index ff27d31b..235de246 100644
--- a/tabcmd/locales/sv/tabcmd_messages_sv.properties
+++ b/tabcmd/locales/sv/tabcmd_messages_sv.properties
@@ -165,6 +165,7 @@ export.errors.need_country_and_languge=Alternativen --country och --language må
 export.errors.white_space_workbook_view=Namnet på den arbetsbok eller vy som ska exporteras får inte innehålla blanksteg. Använd arbetsbokens eller vyns normaliserade namn så som det visas i URL:en.
 export.errors.requires_workbook_view_name=Kommandot ”{0}” kräver ett namn på <workbook>/<view>
 export.errors.requires_workbook_view_param=Kommandot ”{0}” kräver en <workbook>/<view>-parameter, och parametern måste innehålla minst ett snedstreck (/)
+export.errors.requires_valid_custom_view_uuid=The URL for custom views must contain a valid custom view uuid
 export.options.country=Om du inte använder användarens standardspråkzon, så ange språkzonens landsförkortning (finns i IANA Language Subtag Registry). Måste användas med --language
 export.options.csv=Exportera data i CSV-format (standard)
 export.options.fullpdf=Exportera visuella vyer i PDF-format (om arbetsboken publicerades med flikar)
diff --git a/tabcmd/locales/zh/tabcmd_messages_zh.properties b/tabcmd/locales/zh/tabcmd_messages_zh.properties
index 5985a9f8..43fd14c5 100644
--- a/tabcmd/locales/zh/tabcmd_messages_zh.properties
+++ b/tabcmd/locales/zh/tabcmd_messages_zh.properties
@@ -165,6 +165,7 @@ export.errors.need_country_and_languge=选项 --country 和 --language 必须一
 export.errors.white_space_workbook_view=要导出的工作簿或视图的名称不能包括空格。请使用工作簿或视图出现在 URL 中时的标准化名称。
 export.errors.requires_workbook_view_name=“{0}”命令需要一个 <workbook>/<view> 名称
 export.errors.requires_workbook_view_param=“{0}”命令需要一个 <workbook>/<view> 参数，而且在此参数中至少必须有一个斜杠(/)
+export.errors.requires_valid_custom_view_uuid=The URL for custom views must contain a valid custom view uuid
 export.options.country=如果未使用用户的默认区域设置，则为区域设置的国家/地区缩写(在 IANA 语言子标记注册表中查找)。必须与 --language 一起使用
 export.options.csv=将数据导出为 CSV 格式(默认)
 export.options.fullpdf=将可视化视图导出为 PDF 格式(如果发布了带选项卡的工作簿)


Test patch:
diff --git a/.github/workflows/run-e2-tests.yml b/.github/workflows/run-e2-tests.yml
index 0d7d7083..68a8014b 100644
--- a/.github/workflows/run-e2-tests.yml
+++ b/.github/workflows/run-e2-tests.yml
@@ -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 }}
 
diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
index 06e4d87f..fd9a4804 100644
--- a/.github/workflows/run-tests.yml
+++ b/.github/workflows/run-tests.yml
@@ -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 }}
 
diff --git a/tests/commands/test_datasources_and_workbooks_command.py b/tests/commands/test_datasources_and_workbooks_command.py
index 0cbf765b..446822dc 100644
--- a/tests/commands/test_datasources_and_workbooks_command.py
+++ b/tests/commands/test_datasources_and_workbooks_command.py
@@ -14,6 +14,7 @@
 
 getter = MagicMock()
 getter.get = MagicMock("get", return_value=([fake_item], 1))
+getter.get_by_id = MagicMock("get_by_id", return_value=([fake_item], 1))
 
 mock_args = argparse.Namespace()
 
@@ -91,3 +92,9 @@ def test_get_view_by_content_url(self, mock_server):
         DatasourcesAndWorkbooks.get_view_by_content_url(mock_logger, mock_server, content_url)
         getter.get.assert_called()
         # should also assert the filter on content url
+
+    def test_get_custom_view_by_id(self, mock_server):
+        mock_server.custom_views = getter
+        custom_view_id = "cv-id"
+        DatasourcesAndWorkbooks.get_custom_view_by_id(mock_logger, mock_server, custom_view_id)
+        getter.get_by_id.assert_called()
diff --git a/tests/commands/test_geturl_utils.py b/tests/commands/test_geturl_utils.py
index ec63e4df..a7a4b2f2 100644
--- a/tests/commands/test_geturl_utils.py
+++ b/tests/commands/test_geturl_utils.py
@@ -1,11 +1,12 @@
 import unittest
-from typing import Iterator
+import uuid
 from unittest import mock
 
 import tableauserverclient
 
 from tabcmd.commands.datasources_and_workbooks.get_url_command import *
 from tabcmd.commands.datasources_and_workbooks.export_command import *
+from tabcmd.commands.datasources_and_workbooks.datasources_workbooks_views_url_parser import *
 from tabcmd.commands.server import Server
 
 mock_args = argparse.Namespace()
@@ -23,6 +24,12 @@
 fake_item.name = "fake-name"
 fake_item.id = "fake-id"
 
+fake_cv_id = str(uuid.uuid4())
+fake_cv_item = mock.MagicMock(TSC.CustomViewItem)
+fake_cv_item.name = "custom-view-name"
+fake_cv_item.id = fake_cv_id
+fake_cv_item.view.id = fake_item.id
+
 
 class FileHandling(unittest.TestCase):
 
@@ -38,13 +45,13 @@ class FileHandling(unittest.TestCase):
     def test_get_view_with_chars_in_save_name(self):
         filename = "C:\\chase.culver\\docs\\downloaded.twbx"  # W-13757625 fails if file path contains .
         url = None
-        filetype = GetUrl.get_file_type_from_filename(mock_logger, filename, url)
+        filetype = DatasourcesWorkbooksAndViewsUrlParser.get_file_type_from_filename(mock_logger, filename, url)
         assert filetype == "twbx", filetype
 
     def test_evaluate_file_name_pdf(self):
         filename = "filename.pdf"
         url = None
-        filetype = GetUrl.get_file_type_from_filename(mock_logger, filename, url)
+        filetype = DatasourcesWorkbooksAndViewsUrlParser.get_file_type_from_filename(mock_logger, filename, url)
         assert filetype == "pdf", filetype
 
     def test_evaluate_file_name_url(self):
@@ -69,23 +76,161 @@ def test_evaluate_file_name_url_no_ext_fails(self):
 
     def test_get_view_without_extension_that_does_have_one(self):
         filename = "viewname.pdf"
-        assert GetUrl.get_name_without_possible_extension(filename) == "viewname"
+        assert DatasourcesWorkbooksAndViewsUrlParser.get_name_without_possible_extension(filename) == "viewname"
 
     def test_get_view_without_extension_that_doesnt_have_one(self):
         filename = "viewname"
-        assert GetUrl.get_name_without_possible_extension(filename) == filename
+        assert DatasourcesWorkbooksAndViewsUrlParser.get_name_without_possible_extension(filename) == filename
 
 
 # handling our specific url-ish identifiers: /workbook/wb-name, etc
 class GeturlTests(unittest.TestCase):
     def test_get_workbook_name(self):
-        assert GetUrl.get_resource_name("workbooks/wbname", mock_logger) == "wbname"
+        assert DatasourcesWorkbooksAndViewsUrlParser.get_resource_name("workbooks/wbname", mock_logger) == "wbname"
 
     def test_view_name(self):
-        assert GetUrl.get_view_url("views/wb-name/view-name", None) == "wb-name/sheets/view-name"
+        assert (
+            DatasourcesWorkbooksAndViewsUrlParser.get_view_url_from_get_url(mock_logger, "views/wb-name/view-name")
+            == "wb-name/sheets/view-name"
+        )
 
     def test_view_name_with_url_params(self):
-        assert GetUrl.get_view_url("views/wb-name/view-name?:refresh=y", None) == "wb-name/sheets/view-name"
+        assert (
+            DatasourcesWorkbooksAndViewsUrlParser.get_view_url_from_get_url(
+                mock_logger, "views/wb-name/view-name?:refresh=y"
+            )
+            == "wb-name/sheets/view-name"
+        )
+
+    def test_get_url_parts_from_custom_view_url(self):
+        cv_uuid = str(uuid.uuid4())
+        custom_view_url = "views/wb-name/view-name/" + cv_uuid + "/custom-view-name"
+        (
+            view_url,
+            custom_view_id,
+            custom_view_name,
+        ) = DatasourcesWorkbooksAndViewsUrlParser.get_custom_view_parts_from_get_url(mock_logger, custom_view_url)
+        assert view_url == "wb-name/sheets/view-name"
+        assert custom_view_id == cv_uuid
+        assert custom_view_name == "custom-view-name"
+
+    def test_get_url_parts_from_custom_view_url_invalid_cv_id(self):
+        custom_view_url = "views/wb-name/view-name/cv_uuid/custom-view-name"
+        with self.assertRaises(SystemExit):
+            DatasourcesWorkbooksAndViewsUrlParser.get_custom_view_parts_from_get_url(mock_logger, custom_view_url)
+
+    def test_get_url_parts_from_custom_view_url_bad_url(self):
+        custom_view_url = "views/wb-name/view-name/cv_uuid/custom-view-name/kitty"
+        with self.assertRaises(SystemExit):
+            DatasourcesWorkbooksAndViewsUrlParser.get_custom_view_parts_from_get_url(mock_logger, custom_view_url)
+
+    def test_get_url_parts_from_custom_view_url_with_url_params(self):
+        cv_uuid = str(uuid.uuid4())
+        custom_view_url = "views/wb-name/view-name/" + cv_uuid + "/custom-view-name?:refresh=yes"
+        (
+            view_url,
+            custom_view_id,
+            custom_view_name,
+        ) = DatasourcesWorkbooksAndViewsUrlParser.get_custom_view_parts_from_get_url(mock_logger, custom_view_url)
+        assert view_url == "wb-name/sheets/view-name"
+        assert custom_view_id == cv_uuid
+        assert custom_view_name == "custom-view-name"
+
+    def test_get_url_parts_from_custom_view_url_with_file_extension(self):
+        cv_uuid = str(uuid.uuid4())
+        custom_view_url = "views/wb-name/view-name/" + cv_uuid + "/custom-view-name.png"
+        (
+            view_url,
+            custom_view_id,
+            custom_view_name,
+        ) = DatasourcesWorkbooksAndViewsUrlParser.get_custom_view_parts_from_get_url(mock_logger, custom_view_url)
+        assert view_url == "wb-name/sheets/view-name"
+        assert custom_view_id == cv_uuid
+        assert custom_view_name == "custom-view-name"
+
+    def test_parse_get_url_to_view_parts(self):
+        url = "views/wb-name/view-name"
+        (
+            view_url,
+            cv_id,
+            cv_name,
+        ) = DatasourcesWorkbooksAndViewsUrlParser.parse_get_view_url_to_view_and_custom_view_parts(mock_logger, url)
+        assert view_url == "wb-name/sheets/view-name"
+        assert cv_id is None
+        assert cv_name is None
+
+    def test_parse_get_url_to_view_parts_with_params(self):
+        url = "views/wb-name/view-name?params=1"
+        (
+            view_url,
+            cv_id,
+            cv_name,
+        ) = DatasourcesWorkbooksAndViewsUrlParser.parse_get_view_url_to_view_and_custom_view_parts(mock_logger, url)
+        assert view_url == "wb-name/sheets/view-name"
+        assert cv_id is None
+        assert cv_name is None
+
+    def test_parse_get_url_to_view_parts_with_spaces(self):
+        url = "views/wb name/view-name"
+        with self.assertRaises(SystemExit):
+            DatasourcesWorkbooksAndViewsUrlParser.parse_get_view_url_to_view_and_custom_view_parts(mock_logger, url)
+
+    def test_parse_get_url_to_view_parts_without_slashes(self):
+        url = "views\wb name\\view-name"
+        with self.assertRaises(SystemExit):
+            DatasourcesWorkbooksAndViewsUrlParser.parse_get_view_url_to_view_and_custom_view_parts(mock_logger, url)
+
+    def test_parse_get_url_to_custom_view_parts(self):
+        cv_uuid = str(uuid.uuid4())
+        custom_view_url = "views/wb-name/view-name/" + cv_uuid + "/custom-view-name"
+        (
+            view_url,
+            custom_view_id,
+            custom_view_name,
+        ) = DatasourcesWorkbooksAndViewsUrlParser.parse_get_view_url_to_view_and_custom_view_parts(
+            mock_logger, custom_view_url
+        )
+        assert view_url == "wb-name/sheets/view-name"
+        assert custom_view_id == cv_uuid
+        assert custom_view_name == "custom-view-name"
+
+    def test_parse_get_url_to_custom_view_parts_with_file_extension(self):
+        cv_uuid = str(uuid.uuid4())
+        custom_view_url = "views/wb-name/view-name/" + cv_uuid + "/custom-view-name.png"
+        (
+            view_url,
+            custom_view_id,
+            custom_view_name,
+        ) = DatasourcesWorkbooksAndViewsUrlParser.parse_get_view_url_to_view_and_custom_view_parts(
+            mock_logger, custom_view_url
+        )
+        assert view_url == "wb-name/sheets/view-name"
+        assert custom_view_id == cv_uuid
+        assert custom_view_name == "custom-view-name"
+
+    @mock.patch("tableauserverclient.Server")
+    def test_get_url_item_and_item_type_from_view_url(self, mock_server):
+        view_url = "views/wb-name/view-name"
+        mock_server.views = mock.MagicMock()
+        mock_server.views.get = mock.MagicMock("get", return_value=([fake_item], 1))
+        view_item, server_content_type = DatasourcesWorkbooksAndViewsUrlParser.get_url_item_and_item_type_from_view_url(
+            mock_logger, view_url, mock_server
+        )
+        assert view_item == fake_item
+        assert server_content_type == mock_server.views
+
+    @mock.patch("tableauserverclient.Server")
+    def test_get_url_item_and_item_type_from_custom_view_url(self, mock_server):
+        view_url = "views/wb-name/view-name/" + fake_cv_id + "/custom-view-name"
+        mock_server.views = mock.MagicMock()
+        mock_server.views.get = mock.MagicMock("get", return_value=([fake_item], 1))
+        mock_server.custom_views = mock.MagicMock()
+        mock_server.custom_views.get_by_id = mock.MagicMock("get_by_id", return_value=fake_cv_item)
+        cv_item, server_content_type = DatasourcesWorkbooksAndViewsUrlParser.get_url_item_and_item_type_from_view_url(
+            mock_logger, view_url, mock_server
+        )
+        assert cv_item == fake_cv_item
+        assert server_content_type == mock_server.custom_views
 
     """
     GetUrl.get_view(url)
@@ -109,23 +254,109 @@ class ExportTests(unittest.TestCase):
     fake_item.pdf = mock.MagicMock("bytes")
     fake_item.png = mock.MagicMock("bytes")
 
-    def test_parse_export_url_to_workbook_and_view(self):
+    def test_parse_export_url_to_workbook_view_and_custom_view(self):
         wb_url = "wb-name/view-name"
-        view, wb = ExportCommand.parse_export_url_to_workbook_and_view(mock_logger, wb_url)
+        (
+            view,
+            wb,
+            cv_id,
+            cv_name,
+        ) = DatasourcesWorkbooksAndViewsUrlParser.parse_export_url_to_workbook_view_and_custom_view(mock_logger, wb_url)
         assert view == "wb-name/sheets/view-name"
         assert wb == "wb-name"
+        assert cv_id is None
+        assert cv_name is None
 
-    def test_parse_export_url_to_workbook_and_view_with_start_slash(self):
+    def test_parse_export_url_to_workbook_view_and_custom_view_with_start_slash(self):
         wb_url = "/wb-name/view-name"
-        view, wb = ExportCommand.parse_export_url_to_workbook_and_view(mock_logger, wb_url)
+        (
+            view,
+            wb,
+            cv_id,
+            cv_name,
+        ) = DatasourcesWorkbooksAndViewsUrlParser.parse_export_url_to_workbook_view_and_custom_view(mock_logger, wb_url)
         assert view == "wb-name/sheets/view-name"
         assert wb == "wb-name"
+        assert cv_id is None
+        assert cv_name is None
 
-    def test_parse_export_url_to_workbook_and_view_bad_url(self):
+    def test_parse_export_url_to_workbook_view_and_custom_view_bad_url(self):
         wb_url = "wb-name/view-name/kitty"
-        view, wb = ExportCommand.parse_export_url_to_workbook_and_view(mock_logger, wb_url)
+        (
+            view,
+            wb,
+            cv_id,
+            cv_name,
+        ) = DatasourcesWorkbooksAndViewsUrlParser.parse_export_url_to_workbook_view_and_custom_view(mock_logger, wb_url)
+        assert view is None
+        assert wb is None
+        assert cv_id is None
+        assert cv_name is None
+
+    def test_parse_export_url_to_workbook_view_and_custom_view_with_cv_parts(self):
+        cv_uuid = str(uuid.uuid4())
+        custom_view_name = "custom-view-name"
+        wb_url = "/wb-name/view-name/" + cv_uuid + "/" + custom_view_name
+        (
+            view,
+            wb,
+            cv_id,
+            cv_name,
+        ) = DatasourcesWorkbooksAndViewsUrlParser.parse_export_url_to_workbook_view_and_custom_view(mock_logger, wb_url)
+        assert view == "wb-name/sheets/view-name"
+        assert wb == "wb-name"
+        assert cv_id == cv_uuid
+        assert cv_name == custom_view_name
+
+    def test_parse_export_url_to_workbook_view_and_custom_view_with_bad_cv_parts(self):
+        cv_uuid = str(uuid.uuid4())
+        custom_view_name = "custom-view-name"
+        wb_url = "/wb-name/view-name/" + cv_uuid + "/" + custom_view_name + "/kitty"
+        (
+            view,
+            wb,
+            cv_id,
+            cv_name,
+        ) = DatasourcesWorkbooksAndViewsUrlParser.parse_export_url_to_workbook_view_and_custom_view(mock_logger, wb_url)
         assert view is None
         assert wb is None
+        assert cv_id is None
+        assert cv_name is None
+
+    def test_parse_export_url_to_workbook_view_and_custom_view_with_invalid_cv_id(self):
+        wb_url = "/wb-name/view-name/cv-id/cv-name"
+        with self.assertRaises(SystemExit):
+            DatasourcesWorkbooksAndViewsUrlParser.parse_export_url_to_workbook_view_and_custom_view(mock_logger, wb_url)
+
+    @mock.patch("tableauserverclient.Server")
+    def test_get_export_item_and_item_type_for_view(self, mock_server):
+        view_url = "wb-name/sheets/view-name"
+        mock_server.views = mock.MagicMock()
+        mock_server.views.get = mock.MagicMock("get", return_value=([fake_item], 1))
+        (
+            view_item,
+            server_content_type,
+        ) = DatasourcesWorkbooksAndViewsUrlParser.get_export_item_and_server_content_type_from_export_url(
+            view_url, mock_logger, mock_server, None
+        )
+        assert view_item == fake_item
+        assert server_content_type == mock_server.views
+
+    @mock.patch("tableauserverclient.Server")
+    def test_get_export_item_and_item_type_for_custom_view(self, mock_server):
+        view_url = "wb-name/sheets/view-name"
+        mock_server.views = mock.MagicMock()
+        mock_server.views.get = mock.MagicMock("get", return_value=([fake_item], 1))
+        mock_server.custom_views = mock.MagicMock()
+        mock_server.custom_views.get_by_id = mock.MagicMock("get_by_id", return_value=fake_cv_item)
+        (
+            cv_item,
+            server_content_type,
+        ) = DatasourcesWorkbooksAndViewsUrlParser.get_export_item_and_server_content_type_from_export_url(
+            view_url, mock_logger, mock_server, fake_cv_id
+        )
+        assert cv_item == fake_cv_item
+        assert server_content_type == mock_server.custom_views
 
     @mock.patch("tableauserverclient.Server")
     def test_download_csv(self, mock_server):