From ff57dc62643a0e15589006f1d500f9d433350a4a Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Mon, 29 Jun 2026 15:22:59 -0700 Subject: [PATCH 01/28] fix: prompt for password when username given but no password provided (#301) When `tabcmd login -u myuser` is run with no `-p` flag, the session creation now correctly prompts for a password interactively (or exits with an error if `--no-prompt` is set), rather than falling through to the saved-session path and failing with a missing-arguments error. Co-Authored-By: Claude Sonnet 4.6 --- tabcmd/commands/auth/session.py | 4 +++ tests/commands/test_session.py | 62 +++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/tabcmd/commands/auth/session.py b/tabcmd/commands/auth/session.py index cdf0634e..c5f23b2f 100644 --- a/tabcmd/commands/auth/session.py +++ b/tabcmd/commands/auth/session.py @@ -308,6 +308,10 @@ def create_session(self, args, logger): elif args.token_value or args.token_file: self._end_session() credentials = self._create_new_token_credential() + elif args.username: + # username given but no password/token: prompt for password (or exit if --no-prompt) + self._end_session() + credentials = self._create_new_credential(None, Session.PASSWORD_CRED_TYPE) else: # no login arguments given - look for saved info # maybe we're already signed in! if self.tableau_server: diff --git a/tests/commands/test_session.py b/tests/commands/test_session.py index 401f16ae..e427f8c3 100644 --- a/tests/commands/test_session.py +++ b/tests/commands/test_session.py @@ -447,6 +447,68 @@ def test_create_session_server_url_with_extra_path_in_server_arg( assert auth is not None, auth assert new_session.server_url == expected_session_server_url + @mock.patch("tableauserverclient.Server") + def test_create_session_username_only_prompts_for_password( + self, mock_tsc, mock_pass, mock_file, mock_path, mock_json + ): + """When username is given but no password, should call getpass to prompt.""" + name = "myuser" + _set_mocks_for_json_file_exists(mock_path, mock_json, does_it_exist=False) + new_session = Session() + new_session.no_prompt = False # ensure clean state regardless of json mock + new_session.tableau_server = mock_tsc() + _set_mock_signin_validation_succeeds(new_session.tableau_server, name) + mock_pass.return_value = "prompted_password" + + test_args = Namespace(**vars(args_to_mock)) + test_args.username = name + test_args.no_prompt = False + # no password, no token + + auth = new_session.create_session(test_args, None) + assert auth is not None, auth + mock_pass.assert_called() + + @mock.patch("tableauserverclient.Server") + def test_create_session_username_only_no_prompt_exits( + self, mock_tsc, mock_pass, mock_file, mock_path, mock_json + ): + """When username is given but no password and --no-prompt is set, should exit with error.""" + name = "myuser" + _set_mocks_for_json_file_exists(mock_path, mock_json, does_it_exist=False) + new_session = Session() + new_session.no_prompt = True # ensure no_prompt is set before args override + new_session.tableau_server = mock_tsc() + + test_args = Namespace(**vars(args_to_mock)) + test_args.username = name + test_args.no_prompt = True + # no password, no token + + with self.assertRaises(SystemExit): + new_session.create_session(test_args, None) + mock_pass.assert_not_called() + + @mock.patch("tableauserverclient.Server") + def test_create_session_username_and_password_no_prompt( + self, mock_tsc, mock_pass, mock_file, mock_path, mock_json + ): + """When both username and password are given, getpass should not be called.""" + name = "myuser" + _set_mocks_for_json_file_exists(mock_path, mock_json, does_it_exist=False) + new_session = Session() + new_session.tableau_server = mock_tsc() + _set_mock_signin_validation_succeeds(new_session.tableau_server, name) + + test_args = Namespace(**vars(args_to_mock)) + test_args.username = name + test_args.password = "mypassword" + test_args.no_prompt = False + + auth = new_session.create_session(test_args, None) + assert auth is not None, auth + mock_pass.assert_not_called() + def _set_mock_tsc_not_signed_in(mock_tsc): tsc_in_test = mock.MagicMock(name="manually mocking tsc") From a9836b080b40819bc7c9b41bb2ccfc63b4f4a6fa Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Mon, 29 Jun 2026 17:55:18 -0700 Subject: [PATCH 02/28] fix: --filename extension takes precedence over URL extension (#256) When a user supplies -f output.csv with a URL that has a different extension (e.g. view.png), the file type is now determined from the user-supplied filename extension. If the filename has no recognized extension, or no filename is given, the URL extension is used as the fallback. Adds three tests covering the three cases. Co-Authored-By: Claude Sonnet 4.6 --- .../datasources_workbooks_views_url_parser.py | 33 ++++++++++--------- tests/commands/test_geturl_utils.py | 23 +++++++++++++ 2 files changed, 40 insertions(+), 16 deletions(-) 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 index e5d3e4db..2e59320b 100644 --- a/tabcmd/commands/datasources_and_workbooks/datasources_workbooks_views_url_parser.py +++ b/tabcmd/commands/datasources_and_workbooks/datasources_workbooks_views_url_parser.py @@ -73,23 +73,24 @@ def explain_expected_get_url(logger, url: str, command: str): @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)) + valid_extensions = ["pdf", "csv", "png", "twb", "twbx", "tdsx", "tds"] + + # If the user supplied a filename with a recognized extension, that takes precedence. + if file_name: + type_of_file = DatasourcesWorkbooksAndViewsUrlParser.get_file_extension(file_name) + if type_of_file in valid_extensions: + logger.debug("filetype from filename: {}".format(type_of_file)) + return type_of_file + + # Fall back to the URL extension (either no filename was given, or filename had no recognized extension). + if url: + type_of_file = DatasourcesWorkbooksAndViewsUrlParser.get_file_extension(url) + if type_of_file in valid_extensions: + logger.debug("filetype from url: {}".format(type_of_file)) + return type_of_file + + Errors.exit_with_error(logger, _("get.extension.not_found").format(file_name or url)) @staticmethod def get_file_extension(path): diff --git a/tests/commands/test_geturl_utils.py b/tests/commands/test_geturl_utils.py index b5e8dd54..7ebf45e9 100644 --- a/tests/commands/test_geturl_utils.py +++ b/tests/commands/test_geturl_utils.py @@ -447,3 +447,26 @@ def test_save_to_data_file(self): mock_content = mock.MagicMock() filename = "test_out.csv" ExportCommand.save_to_data_file(mock_logger, mock_content, filename) + + +class FilenameExtensionTests(unittest.TestCase): + def test_filename_extension_overrides_url_extension(self): + mock_logger = mock.MagicMock() + result = DatasourcesWorkbooksAndViewsUrlParser.get_file_type_from_filename( + mock_logger, "view.png", "output.csv" + ) + assert result == "csv" + + def test_filename_without_extension_falls_back_to_url_extension(self): + mock_logger = mock.MagicMock() + result = DatasourcesWorkbooksAndViewsUrlParser.get_file_type_from_filename( + mock_logger, "view.png", "output" + ) + assert result == "png" + + def test_no_filename_falls_back_to_url_extension(self): + mock_logger = mock.MagicMock() + result = DatasourcesWorkbooksAndViewsUrlParser.get_file_type_from_filename( + mock_logger, "report.pdf", None + ) + assert result == "pdf" From 652f0a3c07b4de4f5e7d14fd1fafab796dc2a7b3 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Mon, 29 Jun 2026 17:56:22 -0700 Subject: [PATCH 03/28] fix: rotate log file using RotatingFileHandler (#211) Replace filename="tabcmd.log" in logging.basicConfig with a RotatingFileHandler (max 1 MB per file, 5 backups) so the log file no longer grows indefinitely. Co-Authored-By: Claude Sonnet 4.6 --- tabcmd/execution/logger_config.py | 9 ++++++- tests/commands/test_logger_config.py | 39 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 tests/commands/test_logger_config.py diff --git a/tabcmd/execution/logger_config.py b/tabcmd/execution/logger_config.py index 17fe6907..4db6fc7f 100644 --- a/tabcmd/execution/logger_config.py +++ b/tabcmd/execution/logger_config.py @@ -1,4 +1,5 @@ import logging +import logging.handlers import os path = os.path.dirname(os.path.abspath(__file__)) @@ -49,8 +50,14 @@ def configure_log(name: str, logging_level_input: str): FORMATS[logging.INFO] = "%(filename)-10s: %(message)-30s" logging.basicConfig( - level=logging_level, format=log_format, filename="tabcmd.log", filemode="a", datefmt="%Y-%m" "-%d " "%H:%M:%S" + level=logging_level, format=log_format, datefmt="%Y-%m" "-%d " "%H:%M:%S" ) + file_handler = logging.handlers.RotatingFileHandler( + "tabcmd.log", maxBytes=1_000_000, backupCount=5 + ) + file_handler.setLevel(logging_level) + file_handler.setFormatter(logging.Formatter(log_format)) + logging.getLogger().addHandler(file_handler) console = logging.StreamHandler() console.setLevel(logging_level) console.setFormatter(logging.Formatter(log_format)) diff --git a/tests/commands/test_logger_config.py b/tests/commands/test_logger_config.py new file mode 100644 index 00000000..7fa3d837 --- /dev/null +++ b/tests/commands/test_logger_config.py @@ -0,0 +1,39 @@ +import logging +import logging.handlers +import unittest + +from tabcmd.execution.logger_config import configure_log + + +class LoggerConfigTests(unittest.TestCase): + def setUp(self): + # Remove all handlers from root logger before each test to avoid + # cross-test pollution from the cumulative basicConfig/addHandler calls. + root = logging.getLogger() + for handler in root.handlers[:]: + handler.close() + root.removeHandler(handler) + + def test_rotating_file_handler_present(self): + """configure_log must add a RotatingFileHandler to the root logger.""" + configure_log("test_rotate", "DEBUG") + root_handlers = logging.getLogger().handlers + rotating_handlers = [h for h in root_handlers if isinstance(h, logging.handlers.RotatingFileHandler)] + self.assertTrue( + len(rotating_handlers) >= 1, + "Expected at least one RotatingFileHandler on the root logger", + ) + + def test_rotating_file_handler_settings(self): + """RotatingFileHandler must use 1 MB max size and 5 backups.""" + configure_log("test_rotate_settings", "DEBUG") + root_handlers = logging.getLogger().handlers + rotating_handlers = [h for h in root_handlers if isinstance(h, logging.handlers.RotatingFileHandler)] + self.assertTrue(len(rotating_handlers) >= 1, "No RotatingFileHandler found") + handler = rotating_handlers[0] + self.assertEqual(handler.maxBytes, 1_000_000, "maxBytes should be 1_000_000") + self.assertEqual(handler.backupCount, 5, "backupCount should be 5") + + +if __name__ == "__main__": + unittest.main() From 304b9eb5258ce49c3cbf2c90851b2aa6165f4af1 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Mon, 29 Jun 2026 17:56:27 -0700 Subject: [PATCH 04/28] fix: default to https when no protocol given in server URL (#331) Co-Authored-By: Claude Sonnet 4.6 --- tabcmd/commands/auth/session.py | 2 +- tests/commands/test_session.py | 35 +++++++++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/tabcmd/commands/auth/session.py b/tabcmd/commands/auth/session.py index cdf0634e..c9a599d8 100644 --- a/tabcmd/commands/auth/session.py +++ b/tabcmd/commands/auth/session.py @@ -487,7 +487,7 @@ def _remove_json(self): def _get_server_base_url(self, url: str): try: parsed = urlparse(url) - scheme = parsed.scheme or "http" + scheme = parsed.scheme or "https" # If netloc is empty, treat path as netloc and discard any extra path netloc = parsed.netloc or parsed.path.split("/")[0] # Keep only the domain diff --git a/tests/commands/test_session.py b/tests/commands/test_session.py index 401f16ae..9a810254 100644 --- a/tests/commands/test_session.py +++ b/tests/commands/test_session.py @@ -397,7 +397,7 @@ def test_create_session_server_url_with_no_protocol_in_server_arg( args_server = "10ay.online.tableau.com/" test_args.server = args_server - expected_session_server_url = "http://" + args_server.rstrip("/") + expected_session_server_url = "https://" + args_server.rstrip("/") auth = new_session.create_session(test_args, None) assert auth is not None, auth @@ -419,7 +419,7 @@ def test_create_session_server_url_with_no_protocol_and_extra_path_in_server_arg args_server = "10ay.online.tableau.com/#/sitename/views/viewname" test_args.server = args_server - expected_session_server_url = "http://" + args_server.split("/")[0] + expected_session_server_url = "https://" + args_server.split("/")[0] auth = new_session.create_session(test_args, None) assert auth is not None, auth @@ -533,6 +533,37 @@ def test_timeout(self): assert connection._http_options["timeout"] == 10 +class GetServerBaseUrlTests(unittest.TestCase): + """Unit tests for Session._get_server_base_url.""" + + def setUp(self): + self.session = Session() + + def test_no_scheme_defaults_to_https(self): + result = self.session._get_server_base_url("my-tableau-server.com") + assert result == "https://my-tableau-server.com", result + + def test_explicit_http_stays_http(self): + result = self.session._get_server_base_url("http://my-tableau-server.com") + assert result == "http://my-tableau-server.com", result + + def test_explicit_https_stays_https(self): + result = self.session._get_server_base_url("https://my-tableau-server.com") + assert result == "https://my-tableau-server.com", result + + def test_no_scheme_with_path_component(self): + result = self.session._get_server_base_url("my-tableau-server.com/some/path") + assert result == "https://my-tableau-server.com", result + + def test_no_scheme_with_trailing_slash(self): + result = self.session._get_server_base_url("my-tableau-server.com/") + assert result == "https://my-tableau-server.com", result + + def test_https_with_path_strips_path(self): + result = self.session._get_server_base_url("https://my-tableau-server.com/some/path") + assert result == "https://my-tableau-server.com", result + + """ This is too slow for unit tests. From ae9f929e9f0e4c4a8ffd267b811bb2352cc08358 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Wed, 13 Aug 2025 23:47:43 -0700 Subject: [PATCH 05/28] typing: from static methods to class methods --- tabcmd/commands/auth/login_command.py | 6 +++--- tabcmd/commands/auth/logout_command.py | 6 +++--- .../datasources_and_workbooks_command.py | 11 ++++++----- .../datasources_workbooks_views_url_parser.py | 9 +++++---- .../datasources_and_workbooks/delete_command.py | 8 ++++---- .../datasources_and_workbooks/export_command.py | 15 ++++++++------- .../datasources_and_workbooks/get_url_command.py | 6 +++--- .../runschedule_command.py | 6 +++--- .../commands/extracts/create_extracts_command.py | 13 +++++++------ .../commands/extracts/decrypt_extracts_command.py | 8 ++++---- .../commands/extracts/delete_extracts_command.py | 13 +++++++------ .../commands/extracts/encrypt_extracts_command.py | 8 ++++---- tabcmd/commands/extracts/extracts.py | 5 +++-- .../extracts/reencrypt_extracts_command.py | 8 ++++---- .../commands/extracts/refresh_extracts_command.py | 15 ++++++++------- tabcmd/commands/group/create_group_command.py | 6 +++--- tabcmd/commands/group/delete_group_command.py | 8 ++++---- tabcmd/commands/project/create_project_command.py | 11 ++++++----- tabcmd/commands/project/delete_project_command.py | 10 +++++----- .../commands/project/publish_samples_command.py | 6 +++--- tabcmd/commands/site/create_site_command.py | 8 ++++---- tabcmd/commands/site/delete_site_command.py | 8 ++++---- tabcmd/commands/site/edit_site_command.py | 8 ++++---- tabcmd/commands/site/list_command.py | 6 +++--- tabcmd/commands/site/list_sites_command.py | 8 ++++---- tabcmd/commands/user/add_users_command.py | 14 +++++++++----- tabcmd/commands/user/create_site_users.py | 9 +++++---- tabcmd/commands/user/create_users_command.py | 6 +++--- tabcmd/commands/user/delete_site_users_command.py | 6 +++--- tabcmd/commands/user/remove_users_command.py | 6 +++--- tabcmd/execution/parent_parser.py | 3 +-- tabcmd/tabcmd.py | 3 ++- 32 files changed, 138 insertions(+), 125 deletions(-) diff --git a/tabcmd/commands/auth/login_command.py b/tabcmd/commands/auth/login_command.py index d042f724..8d0e9dbf 100644 --- a/tabcmd/commands/auth/login_command.py +++ b/tabcmd/commands/auth/login_command.py @@ -17,9 +17,9 @@ def define_args(parser): # just uses global options pass - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() session.create_session(args, logger) diff --git a/tabcmd/commands/auth/logout_command.py b/tabcmd/commands/auth/logout_command.py index b15f8246..f5848e60 100644 --- a/tabcmd/commands/auth/logout_command.py +++ b/tabcmd/commands/auth/logout_command.py @@ -17,9 +17,9 @@ def define_args(parser): # has no options pass - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() session.end_session_and_clear_data() 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 4b1e2f31..d7628e61 100644 --- a/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py +++ b/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py @@ -5,6 +5,7 @@ from tabcmd.commands.constants import Errors from tabcmd.commands.server import Server from tabcmd.execution.localize import _ +RequestOptionsType = TSC.ExcelRequestOptions | TSC.CSVRequestOptions | TSC.PDFRequestOptions | TSC.ImageRequestOptions class DatasourcesAndWorkbooks(Server): @@ -13,7 +14,7 @@ class DatasourcesAndWorkbooks(Server): """ def __init__(self, args): - super().__init__(args) + pass @staticmethod def get_view_url_from_names(wb_name, view_name): @@ -62,7 +63,7 @@ def get_ds_by_content_url(logger, server, datasource_content_url) -> TSC.Datasou return matching_datasources[0] @staticmethod - def apply_values_from_url_params(logger, request_options: TSC.RequestOptions, url) -> None: + def apply_values_from_url_params(logger, request_options: RequestOptionsType, url) -> None: logger.debug(url) try: if "?" in url: @@ -86,7 +87,7 @@ def apply_values_from_url_params(logger, request_options: TSC.RequestOptions, ur # this is called from within from_url_params, for each view_filter value @staticmethod - def apply_encoded_filter_value(logger, request_options, value): + def apply_encoded_filter_value(logger, request_options: RequestOptionsType, value): # the REST API doesn't appear to have the option to disambiguate with "Parameters." value = value.replace("Parameters.", "") # the filter values received from the url are already url encoded. tsc will encode them again. @@ -99,7 +100,7 @@ def apply_encoded_filter_value(logger, request_options, value): # from apply_options, which expects an un-encoded input, # or from apply_url_params via apply_encoded_filter_value which decodes the input @staticmethod - def apply_filter_value(logger, request_options: TSC.RequestOptions, value: str) -> None: + def apply_filter_value(logger, request_options: RequestOptionsType, value: str) -> None: logger.debug("handling filter param {}".format(value)) data_filter = value.split("=") # we should export the _DataExportOptions class from tsc @@ -108,7 +109,7 @@ def apply_filter_value(logger, request_options: TSC.RequestOptions, value: str) # this is called from within from_url_params, for each param value # expects either ImageRequestOptions or PDFRequestOptions @staticmethod - def apply_options_in_url(logger, request_options: TSC.RequestOptions, value: str) -> None: + def apply_options_in_url(logger, request_options: RequestOptionsType, value: str) -> None: logger.debug("handling url option {}".format(value)) setting = value.split("=") if len(setting) != 2: 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 index e5d3e4db..3048efad 100644 --- a/tabcmd/commands/datasources_and_workbooks/datasources_workbooks_views_url_parser.py +++ b/tabcmd/commands/datasources_and_workbooks/datasources_workbooks_views_url_parser.py @@ -1,5 +1,6 @@ import os +import tableauserverclient as TSC from uuid import UUID from tabcmd.commands.constants import Errors @@ -14,7 +15,7 @@ class DatasourcesWorkbooksAndViewsUrlParser(Server): """ def __init__(self, args): - super().__init__(args) + pass @staticmethod def get_view_url_from_names(wb_name, view_name): @@ -179,12 +180,12 @@ def get_url_item_and_item_type_from_view_url(logger, url, server): @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) + item: TSC.ViewItem | TSC.CustomViewItem = 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: + custom_view_item: TSC.CustomViewItem = DatasourcesAndWorkbooks.get_custom_view_by_id(logger, server, custom_view_id) + if not custom_view_item.view or 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 diff --git a/tabcmd/commands/datasources_and_workbooks/delete_command.py b/tabcmd/commands/datasources_and_workbooks/delete_command.py index 3e82e081..71085c74 100644 --- a/tabcmd/commands/datasources_and_workbooks/delete_command.py +++ b/tabcmd/commands/datasources_and_workbooks/delete_command.py @@ -27,9 +27,9 @@ def define_args(delete_parser): set_project_r_arg(group) set_parent_project_arg(group) - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) @@ -75,4 +75,4 @@ def run_command(args): server.datasources.delete(item_to_delete.id) logger.info(_("common.output.succeeded")) except Exception as e: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) diff --git a/tabcmd/commands/datasources_and_workbooks/export_command.py b/tabcmd/commands/datasources_and_workbooks/export_command.py index ac1ed1b3..9881bd44 100644 --- a/tabcmd/commands/datasources_and_workbooks/export_command.py +++ b/tabcmd/commands/datasources_and_workbooks/export_command.py @@ -13,6 +13,7 @@ pagesize = TSC.PDFRequestOptions.PageType # type alias for brevity pageorientation = TSC.PDFRequestOptions.Orientation imageresolution = TSC.ImageRequestOptions.Resolution +RequestOptionsType = TSC.ExcelRequestOptions | TSC.CSVRequestOptions | TSC.PDFRequestOptions | TSC.ImageRequestOptions ImageResolutionStandard = "standard" @@ -81,9 +82,9 @@ def define_args(export_parser): it to a file. This command can also export just the data used for a view_name """ - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) @@ -97,7 +98,7 @@ def run_command(args): if not view_content_url and not wb_content_url: view_example = "/workbook_name/view_name" message = "{} [{}]".format( - _("export.errors.requires_workbook_view_param").format(__class__.__name__), view_example + _("export.errors.requires_workbook_view_param").format("ExportCommand"), view_example ) Errors.exit_with_error(logger, message) @@ -127,7 +128,7 @@ def run_command(args): default_filename = "{}.png".format(export_item.name) except TSC.ServerResponseError as e: - Errors.exit_with_error(logger, _("publish.errors.unexpected_server_response").format(""), e) + Errors.exit_with_error(logger, _("publish.errors.unexpected_server_response").format(""), exception=e) except Exception as e: Errors.exit_with_error(logger, exception=e) try: @@ -138,10 +139,10 @@ def run_command(args): ExportCommand.save_to_file(logger, output, save_name) except Exception as e: - Errors.exit_with_error(logger, "Error saving to file", e) + Errors.exit_with_error(logger, "Error saving to file", exception=e) @staticmethod - def apply_filters_from_args(request_options: TSC.RequestOptions, args, logger=None) -> None: + def apply_filters_from_args(request_options: RequestOptionsType, args, logger=None) -> None: if args.filter: logger.debug("filter = {}".format(args.filter)) params = args.filter.split("&") diff --git a/tabcmd/commands/datasources_and_workbooks/get_url_command.py b/tabcmd/commands/datasources_and_workbooks/get_url_command.py index c0ea0b72..fb5cb385 100644 --- a/tabcmd/commands/datasources_and_workbooks/get_url_command.py +++ b/tabcmd/commands/datasources_and_workbooks/get_url_command.py @@ -31,14 +31,14 @@ def define_args(get_url_parser): # tabcmd get "/views/Finance/InvestmentGrowth.png?:size=640,480" -f growth.png # tabcmd get "/views/Finance/InvestmentGrowth.png?:refresh=yes" -f growth.png - @staticmethod - def run_command(args): + @classmethod + def run_command(cls, args): # A view can be returned in PDF, PNG, or CSV (summary data only) format. # A Tableau workbook is returned as a TWB if it connects to a datasource/live connection, # or a TWBX if it uses an extract. # A Tableau datasource is returned as a TDS if it connects to a live connection, # or a TDSX if it uses an extract. - logger = log(__class__.__name__, args.logging_level) + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) diff --git a/tabcmd/commands/datasources_and_workbooks/runschedule_command.py b/tabcmd/commands/datasources_and_workbooks/runschedule_command.py index 3f39d499..e6685d0f 100644 --- a/tabcmd/commands/datasources_and_workbooks/runschedule_command.py +++ b/tabcmd/commands/datasources_and_workbooks/runschedule_command.py @@ -18,9 +18,9 @@ def define_args(runschedule_parser): group = runschedule_parser.add_argument_group(title=RunSchedule.name) group.add_argument("schedule", help="Name of the schedule to run") - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) diff --git a/tabcmd/commands/extracts/create_extracts_command.py b/tabcmd/commands/extracts/create_extracts_command.py index 16f3b282..8378aa0f 100644 --- a/tabcmd/commands/extracts/create_extracts_command.py +++ b/tabcmd/commands/extracts/create_extracts_command.py @@ -26,9 +26,9 @@ def define_args(create_extract_parser): set_project_arg(group) set_parent_project_arg(group) - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) @@ -39,9 +39,10 @@ def run_command(args): ) try: item = Extracts.get_wb_or_ds_for_extracts(args, logger, server) + job: TSC.JobItem if args.datasource: logger.info(_("createextracts.for.datasource").format(args.datasource)) - job: TSC.JobItem = server.datasources.create_extract(item, encrypt=args.encrypt) + job = server.datasources.create_extract(item, encrypt=args.encrypt) else: if not args.include_all and not args.embedded_datasources: @@ -51,7 +52,7 @@ def run_command(args): ) logger.info(_("createextracts.for.workbook_name").format(args.workbook)) - job: TSC.JobItem = server.workbooks.create_extract( + job = server.workbooks.create_extract( item, encrypt=args.encrypt, includeAll=args.include_all, @@ -62,7 +63,7 @@ def run_command(args): if args.continue_if_exists and Errors.is_resource_conflict(e): logger.info(_("errors.xmlapi.already_exists").format(_("tabcmd.content_type.extract"), args.name)) return - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) logger.info(_("common.output.job_queued_success")) logger.debug("Extract creation queued with JobID: {}".format(job.id)) diff --git a/tabcmd/commands/extracts/decrypt_extracts_command.py b/tabcmd/commands/extracts/decrypt_extracts_command.py index cb47d7fa..8f7eae99 100644 --- a/tabcmd/commands/extracts/decrypt_extracts_command.py +++ b/tabcmd/commands/extracts/decrypt_extracts_command.py @@ -19,9 +19,9 @@ def define_args(decrypt_extract_parser): group = decrypt_extract_parser.add_argument_group(title=DecryptExtracts.name) group.add_argument("site_name", metavar="site-name", help=_("editsite.options.site-name")) - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) @@ -30,7 +30,7 @@ def run_command(args): logger.info(_("decryptextracts.status").format(args.site_name)) job = server.sites.decrypt_extracts(site_item.id) except Exception as e: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) logger.info(_("common.output.job_queued_success")) logger.debug("Extract decryption queued with JobID: {}".format(job.id)) diff --git a/tabcmd/commands/extracts/delete_extracts_command.py b/tabcmd/commands/extracts/delete_extracts_command.py index ab32a458..e9b2981f 100644 --- a/tabcmd/commands/extracts/delete_extracts_command.py +++ b/tabcmd/commands/extracts/delete_extracts_command.py @@ -25,17 +25,18 @@ def define_args(delete_extract_parser): set_project_arg(group) set_parent_project_arg(group) - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) try: item = Extracts.get_wb_or_ds_for_extracts(args, logger, server) + job: TSC.JobItem if args.datasource: logger.info(_("deleteextracts.for.datasource").format(args.datasource)) - job: TSC.JobItem = server.datasources.delete_extract(item) + job = server.datasources.delete_extract(item) else: if not args.include_all and not args.embedded_datasources: Errors.exit_with_error( @@ -43,12 +44,12 @@ def run_command(args): _("extracts.workbook.errors.requires_datasources_or_include_all").format("deleteextracts"), ) logger.info(_("deleteextracts.for.workbook_name").format(args.workbook)) - job: TSC.JobItem = server.workbooks.delete_extract( + job = server.workbooks.delete_extract( item, includeAll=args.include_all, datasources=args.embedded_datasources ) except Exception as e: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) logger.info(_("common.output.job_queued_success")) logger.debug("Extract deletion queued with JobID: {}".format(job.id)) diff --git a/tabcmd/commands/extracts/encrypt_extracts_command.py b/tabcmd/commands/extracts/encrypt_extracts_command.py index 0454d0ea..42d70cbd 100644 --- a/tabcmd/commands/extracts/encrypt_extracts_command.py +++ b/tabcmd/commands/extracts/encrypt_extracts_command.py @@ -21,9 +21,9 @@ def define_args(encrypt_extract_parser): group = encrypt_extract_parser.add_argument_group(title=EncryptExtracts.name) group.add_argument("site_name", metavar="site-name", help=_("editsite.options.site-name")) - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) @@ -32,7 +32,7 @@ def run_command(args): logger.info(_("encryptextracts.status").format(site_item.name)) job = server.sites.encrypt_extracts(site_item.id) except Exception as e: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) logger.info(_("common.output.job_queued_success")) logger.debug("Extract encryption queued with JobID: {}".format(job.id)) diff --git a/tabcmd/commands/extracts/extracts.py b/tabcmd/commands/extracts/extracts.py index 60c63c00..3d2cdedd 100644 --- a/tabcmd/commands/extracts/extracts.py +++ b/tabcmd/commands/extracts/extracts.py @@ -18,10 +18,11 @@ def get_wb_or_ds_for_extracts(args, logger, server): return datasource elif args.workbook or args.url: + workbook_item: TSC.WorkbookItem if args.workbook: - workbook_item: TSC.WorkbookItem = Server.get_workbook_item(logger, server, args.workbook, container) + workbook_item = Server.get_workbook_item(logger, server, args.workbook, container) else: - workbook_item: TSC.WorkbookItem = DatasourcesAndWorkbooks.get_wb_by_content_url( + workbook_item = DatasourcesAndWorkbooks.get_wb_by_content_url( logger, server, args.url ) logger.info(_("export.status").format(workbook_item.name)) diff --git a/tabcmd/commands/extracts/reencrypt_extracts_command.py b/tabcmd/commands/extracts/reencrypt_extracts_command.py index e8eb363a..3026fff9 100644 --- a/tabcmd/commands/extracts/reencrypt_extracts_command.py +++ b/tabcmd/commands/extracts/reencrypt_extracts_command.py @@ -21,9 +21,9 @@ def define_args(reencrypt_extract_parser): group = reencrypt_extract_parser.add_argument_group(title=ReencryptExtracts.name) group.add_argument("site_name", metavar="site-name", help=_("editsite.options.site-name")) - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) @@ -32,7 +32,7 @@ def run_command(args): logger.info(_("reencryptextracts.status").format(site_item.name)) job = server.sites.encrypt_extracts(site_item.id) except Exception as e: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) logger.info(_("common.output.job_queued_success")) logger.debug("Extract re-encryption queued with JobID: {}".format(job.id)) diff --git a/tabcmd/commands/extracts/refresh_extracts_command.py b/tabcmd/commands/extracts/refresh_extracts_command.py index 4286e61f..ab978978 100644 --- a/tabcmd/commands/extracts/refresh_extracts_command.py +++ b/tabcmd/commands/extracts/refresh_extracts_command.py @@ -24,9 +24,9 @@ def define_args(refresh_extract_parser): set_parent_project_arg(group) set_sync_wait_options(group) - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) @@ -38,15 +38,16 @@ def run_command(args): try: item = Extracts.get_wb_or_ds_for_extracts(args, logger, server) + job: TSC.JobItem if args.datasource: logger.info(_("refreshextracts.status_refreshed").format(_("content_type.datasource"), args.datasource)) - job: TSC.JobItem = server.datasources.refresh(item.id, incremental_refresh) + job = server.datasources.refresh(item.id, incremental_refresh) else: - job: TSC.JobItem = server.workbooks.refresh(item.id, incremental_refresh) + job = server.workbooks.refresh(item.id, incremental_refresh) logger.info(_("refreshextracts.status_refreshed").format(_("content_type.workbook"), args.workbook)) except Exception as e: - Errors.exit_with_error(logger, _("refreshextracts.errors.error"), e) + Errors.exit_with_error(logger, _("refreshextracts.errors.error"), exception=e) logger.info(_("common.output.job_queued_success")) logger.debug("Extract refresh queued with JobID: {}".format(job.id)) @@ -59,4 +60,4 @@ def run_command(args): logger.info("Job completed: ") logger.info(job_done) except Exception as je: - Errors.exit_with_error(logger, je) + Errors.exit_with_error(logger, exception=je) diff --git a/tabcmd/commands/group/create_group_command.py b/tabcmd/commands/group/create_group_command.py index 36d7c5f8..ff04039f 100644 --- a/tabcmd/commands/group/create_group_command.py +++ b/tabcmd/commands/group/create_group_command.py @@ -20,9 +20,9 @@ def define_args(create_group_parser): args_group = create_group_parser.add_argument_group(title=CreateGroupCommand.name) args_group.add_argument("name") - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) diff --git a/tabcmd/commands/group/delete_group_command.py b/tabcmd/commands/group/delete_group_command.py index c1fca0b8..bc3f1f49 100644 --- a/tabcmd/commands/group/delete_group_command.py +++ b/tabcmd/commands/group/delete_group_command.py @@ -20,9 +20,9 @@ def define_args(delete_group_parser): args_group = delete_group_parser.add_argument_group(title=DeleteGroupCommand.name) args_group.add_argument("name") - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) @@ -33,4 +33,4 @@ def run_command(args): server.groups.delete(group_id) logger.info(_("common.output.succeeded")) except Exception as e: - Errors.exit_with_error(logger, _("errors.bad_request.detail.generic_delete_groups_error"), e) + Errors.exit_with_error(logger, _("errors.bad_request.detail.generic_delete_groups_error"), exception=e) diff --git a/tabcmd/commands/project/create_project_command.py b/tabcmd/commands/project/create_project_command.py index ef40875f..e5096061 100644 --- a/tabcmd/commands/project/create_project_command.py +++ b/tabcmd/commands/project/create_project_command.py @@ -25,9 +25,9 @@ def define_args(create_project_parser): set_parent_project_arg(create_project_parser) set_description_arg(create_project_parser) - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) @@ -36,9 +36,10 @@ def run_command(args): if args.parent_project_path: try: logger.debug(_("tabcmd.find.parent_project").format(args.parent_project_path)) + # use none for the readable name because we are just looking for the parent path parent = Server.get_project_by_name_and_parent_path(logger, server, None, args.parent_project_path) except Exception as exc: - Errors.exit_with_error(logger, exc) + Errors.exit_with_error(logger, exception=exc) readable_name = "{0}/{1}".format(args.parent_project_path, args.project_name) parent_id = parent.id logger.debug("parent project = `{0}`, id = {1}".format(args.parent_project_path, parent_id)) @@ -54,6 +55,6 @@ def run_command(args): logger.info(_("errors.xmlapi.already_exists").format(_("content_type.project"), args.project_name)) logger.info(_("common.output.succeeded")) else: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) return project_item diff --git a/tabcmd/commands/project/delete_project_command.py b/tabcmd/commands/project/delete_project_command.py index b107406c..550b15b1 100644 --- a/tabcmd/commands/project/delete_project_command.py +++ b/tabcmd/commands/project/delete_project_command.py @@ -22,9 +22,9 @@ def define_args(delete_project_parser): args_group.add_argument("project_name", metavar="project-name", help=_("createproject.options.name")) set_parent_project_arg(args_group) - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) @@ -37,7 +37,7 @@ def run_command(args): logger, server, args.project_name, args.parent_project_path ) except Exception as e: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) project_id = project.id try: @@ -45,4 +45,4 @@ def run_command(args): server.projects.delete(project_id) logger.info(_("common.output.succeeded")) except Exception as e: - Errors.exit_with_error(logger, "tabcmd.result.failure.delete.project", e) + Errors.exit_with_error(logger, "tabcmd.result.failure.delete.project", exception=e) diff --git a/tabcmd/commands/project/publish_samples_command.py b/tabcmd/commands/project/publish_samples_command.py index a8532934..273575b2 100644 --- a/tabcmd/commands/project/publish_samples_command.py +++ b/tabcmd/commands/project/publish_samples_command.py @@ -26,9 +26,9 @@ def define_args(publish_samples_parser): ) set_parent_project_arg(args_group) # args.parent_project_name - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) diff --git a/tabcmd/commands/site/create_site_command.py b/tabcmd/commands/site/create_site_command.py index f684ba1f..947534d9 100644 --- a/tabcmd/commands/site/create_site_command.py +++ b/tabcmd/commands/site/create_site_command.py @@ -22,9 +22,9 @@ def define_args(create_site_parser): args_group.add_argument("new_site_name", metavar="site-name", help=_("editsite.options.site-name")) set_common_site_args(args_group) - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) @@ -46,4 +46,4 @@ def run_command(args): if Errors.is_resource_conflict(e) and args.continue_if_exists: logger.info(_("createsite.errors.site_name_already_exists").format(args.new_site_name)) return - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) diff --git a/tabcmd/commands/site/delete_site_command.py b/tabcmd/commands/site/delete_site_command.py index 672d0141..6404d4b2 100644 --- a/tabcmd/commands/site/delete_site_command.py +++ b/tabcmd/commands/site/delete_site_command.py @@ -20,9 +20,9 @@ def define_args(delete_site_parser): args_group = delete_site_parser.add_argument_group(title=DeleteSiteCommand.name) args_group.add_argument("site_name_to_delete", metavar="site-name", help=_("tabcmd.options.delete_site.name")) - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) @@ -33,4 +33,4 @@ def run_command(args): server.sites.delete(target_site_id) logger.info(_("tabcmd.deletesite.success").format(args.site_name_to_delete)) except Exception as e: - Errors.exit_with_error(logger, _("tabcmd.deletesite.error"), e) + Errors.exit_with_error(logger, _("tabcmd.deletesite.error"), exception=e) diff --git a/tabcmd/commands/site/edit_site_command.py b/tabcmd/commands/site/edit_site_command.py index ab6451ed..8b92a8ae 100644 --- a/tabcmd/commands/site/edit_site_command.py +++ b/tabcmd/commands/site/edit_site_command.py @@ -25,9 +25,9 @@ def define_args(edit_site_parser): set_common_site_args(args_group) set_site_status_arg(args_group) - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) @@ -47,4 +47,4 @@ def run_command(args): logger.info(_("common.output.succeeded")) except Exception as e: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) diff --git a/tabcmd/commands/site/list_command.py b/tabcmd/commands/site/list_command.py index ef07a940..96a54bef 100644 --- a/tabcmd/commands/site/list_command.py +++ b/tabcmd/commands/site/list_command.py @@ -31,8 +31,8 @@ def define_args(list_parser): data_group.add_argument("-d", "--details", action="store_true", help=_("tabcmd.listing.help.details")) data_group.add_argument("-a", "--address", action="store_true", help=_("tabcmd.listing.help.address")) - @staticmethod - def run_command(args): + @classmethod + def run_command(cls, args): logger = log(__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() @@ -87,7 +87,7 @@ def run_command(args): # TODO: do we want this line if it is csv output? logger.info(_("tabcmd.listing.summary").format(len(items), content_type)) except Exception as e: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) @staticmethod def format_children_listing(args, server, content_type, item): diff --git a/tabcmd/commands/site/list_sites_command.py b/tabcmd/commands/site/list_sites_command.py index 646a41db..6f204483 100644 --- a/tabcmd/commands/site/list_sites_command.py +++ b/tabcmd/commands/site/list_sites_command.py @@ -21,9 +21,9 @@ def define_args(list_site_parser): group = list_site_parser.add_argument_group(title=ListSiteCommand.name) set_site_detail_option(group) - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) @@ -35,4 +35,4 @@ def run_command(args): if args.get_extract_encryption_mode: logger.info("EXTRACTENCRYPTION: {}".format(site.extract_encryption_mode)) except Exception as e: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) diff --git a/tabcmd/commands/user/add_users_command.py b/tabcmd/commands/user/add_users_command.py index be3d5d58..3e591087 100644 --- a/tabcmd/commands/user/add_users_command.py +++ b/tabcmd/commands/user/add_users_command.py @@ -3,7 +3,7 @@ from tabcmd.execution.localize import _ from tabcmd.execution.logger_config import log from .user_data import UserCommand - +import sys class AddUserCommand(UserCommand): """ @@ -20,13 +20,17 @@ def define_args(add_user_parser): set_users_file_arg(args_group) set_completeness_options(args_group) - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) logger.info(_("addusers.status").format(args.users.name, args.name)) - UserCommand.act_on_users(logger, server, "added", server.groups.add_user, args) + try: + UserCommand.act_on_users(logger, server, "added", server.groups.add_user, args) + except Exception as e: + logger.error(e.__cause__) + sys.exit(1) diff --git a/tabcmd/commands/user/create_site_users.py b/tabcmd/commands/user/create_site_users.py index 98772b20..d9229b72 100644 --- a/tabcmd/commands/user/create_site_users.py +++ b/tabcmd/commands/user/create_site_users.py @@ -26,9 +26,9 @@ def define_args(create_site_users_parser): set_completeness_options(args_group) UserCommand.set_auth_arg(args_group) - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) @@ -59,9 +59,10 @@ def run_command(args): if Errors.is_resource_conflict(e) and args.continue_if_exists: logger.debug(_("createsite.errors.site_name_already_exists").format(user_obj.name)) else: + logger.debug(type(e)) number_of_errors += 1 logger.debug(number_of_errors) - error_list.append(e.summary + ": " + e.detail) + error_list.append(e.__class__.__name__) # + ": " + e.__cause__ or "Unknown") logger.debug(error_list) logger.info(_("session.monitorjob.percent_complete").format(100)) logger.info(_("importcsvsummary.line.processed").format(number_of_users_listed)) diff --git a/tabcmd/commands/user/create_users_command.py b/tabcmd/commands/user/create_users_command.py index f44ee333..ab2a92a2 100644 --- a/tabcmd/commands/user/create_users_command.py +++ b/tabcmd/commands/user/create_users_command.py @@ -26,9 +26,9 @@ def define_args(create_users_parser): set_completeness_options(args_group) UserCommand.set_auth_arg(args_group) - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) diff --git a/tabcmd/commands/user/delete_site_users_command.py b/tabcmd/commands/user/delete_site_users_command.py index 6c9c112c..4b23594b 100644 --- a/tabcmd/commands/user/delete_site_users_command.py +++ b/tabcmd/commands/user/delete_site_users_command.py @@ -23,9 +23,9 @@ def define_args(delete_site_users_parser): set_users_file_positional(args_group) set_completeness_options(args_group) - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) diff --git a/tabcmd/commands/user/remove_users_command.py b/tabcmd/commands/user/remove_users_command.py index d9b86adc..c4e9c558 100644 --- a/tabcmd/commands/user/remove_users_command.py +++ b/tabcmd/commands/user/remove_users_command.py @@ -20,9 +20,9 @@ def define_args(remove_users_parser): set_users_file_arg(args_group) set_completeness_options(args_group) - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) diff --git a/tabcmd/execution/parent_parser.py b/tabcmd/execution/parent_parser.py index 495f8ba2..14f55506 100644 --- a/tabcmd/execution/parent_parser.py +++ b/tabcmd/execution/parent_parser.py @@ -192,10 +192,9 @@ def include_help(self): class Help: - parser = None # This needs to have access to the parser when it gets called def __init__(self, _parser: ParentParser): - self.parser = _parser + self.parser: ParentParser = _parser def run_command(self, args): logger = log(__name__, "info") diff --git a/tabcmd/tabcmd.py b/tabcmd/tabcmd.py index 70b8b63d..44904041 100644 --- a/tabcmd/tabcmd.py +++ b/tabcmd/tabcmd.py @@ -17,11 +17,12 @@ def main(): print("Keyboard Interrupt: exiting") sys.exit(1) except Exception as e: + traceback_info = f"at line {e.__traceback__.tb_lineno} of {__file__}" if e.__traceback__ else "traceback unavailable" sys.stderr.writelines( [ "ERROR\n", "Unhandled exception: {}\n".format(type(e).__name__), - f"at line {e.__traceback__.tb_lineno} of {__file__}: {e}\n", + f"{traceback_info}: {e}\n", ] ) sys.exit(1) From ad783869dfd711458d1dc16e69ca21f795db2ea4 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Wed, 13 Aug 2025 23:48:58 -0700 Subject: [PATCH 06/28] type fixes: session.py --- tabcmd/commands/auth/session.py | 77 +++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 32 deletions(-) diff --git a/tabcmd/commands/auth/session.py b/tabcmd/commands/auth/session.py index cdf0634e..cce15d8f 100644 --- a/tabcmd/commands/auth/session.py +++ b/tabcmd/commands/auth/session.py @@ -13,7 +13,7 @@ from tabcmd.execution.localize import _ from tabcmd.execution.logger_config import log -from typing import Dict, Any +from typing import Dict, Any, Optional class Session: @@ -28,31 +28,31 @@ class Session: PASSWORD_CRED_TYPE = "password" def __init__(self): - self.username = None + self.username: Optional[str] = None # we don't store the password - self.user_id = None - self.auth_token = None - self.token_name = None - self.token_value = None - self.password_file = None - self.token_file = None - self.site_name = None # The site name, e.g 'alpodev' - self.site_id = None # The site id, e.g 'abcd-1234-1234-1244-1234' - self.server_url = None - self.last_command = None # for when we have to renew the session then re-try - self.last_login_using = None - - self.no_prompt = False - self.certificate = None - self.no_certcheck = False - self.no_proxy = False - self.proxy = None - self.timeout = None - - self.logging_level = "info" + self.user_id: Optional[str] = None + self.auth_token: Optional[str] = None + self.token_name: Optional[str] = None + self.token_value: Optional[str] = None + self.password_file: Optional[str] = None + self.token_file: Optional[str] = None + self.site_name: Optional[str] = None # The site name, e.g 'alpodev' + self.site_id: Optional[str] = None # The site id, e.g 'abcd-1234-1234-1244-1234' + self.server_url: Optional[str] = None + self.last_command: Optional[str] = None # for when we have to renew the session then re-try + self.last_login_using: Optional[str] = None + + self.no_prompt: bool = False + self.certificate: Optional[str] = None + self.no_certcheck: bool = False + self.no_proxy: bool = False + self.proxy: Optional[str] = None + self.timeout: Optional[int] = None + + self.logging_level: str = "info" self.logger = log(__name__, self.logging_level) # instantiate here mostly for tests self._read_from_json() - self.tableau_server = None # this one is an object that doesn't get persisted in the file + self.tableau_server: Optional[TSC.Server] = None # this one is an object that doesn't get persisted in the file # called before we connect to the server # generally, we don't want to overwrite stored data with nulls @@ -201,6 +201,10 @@ def _open_connection_with_opts(self) -> TSC.Server: return tableau_server def _verify_server_connection_unauthed(self): + if not self.tableau_server: + Errors.exit_with_error(self.logger, "No server connection available") + + assert self.tableau_server is not None # Type hint for mypy try: self.tableau_server.use_server_version() except requests.exceptions.ReadTimeout as timeout_error: @@ -222,7 +226,12 @@ def _create_new_connection(self) -> TSC.Server: try: self.tableau_server = self._open_connection_with_opts() except Exception as e: - Errors.exit_with_error(self.logger, "Failed to connect to server", e) + Errors.exit_with_error(self.logger, "Failed to connect to server", exception=e) + + if not self.tableau_server: + Errors.exit_with_error(self.logger, "Failed to create server connection") + + assert self.tableau_server is not None # Type hint for mypy return self.tableau_server def _read_existing_state(self): @@ -246,7 +255,7 @@ def _print_server_info(self): def _validate_existing_signin(self): # when do these two messages show up? self.logger.info(_("session.auto_site_login")) try: - if self.tableau_server and self.tableau_server.is_signed_in(): + if self.tableau_server and self.tableau_server.is_signed_in() and self.user_id: server_user = self.tableau_server.users.get_by_id(self.user_id).name if not self.username: self.logger.info("Fetched user details from server") @@ -261,8 +270,12 @@ def _validate_existing_signin(self): # server connection created, not yet logged in def _sign_in(self, tableau_auth) -> TSC.Server: - self.logger.debug(_("session.login") + self.server_url) + if not self.tableau_server: + Errors.exit_with_error(self.logger, "No server connection available for sign in") + + self.logger.debug(_("session.login") + (self.server_url or "")) self.logger.debug(_("listsites.output").format("", self.username or self.token_name, self.site_name)) + assert self.tableau_server is not None # Type hint for mypy try: self.tableau_server.auth.sign_in(tableau_auth) # it's the same call for token or user-pass except Exception as e: @@ -298,7 +311,7 @@ def create_session(self, args, logger): self._read_existing_state() self._update_session_data(args) self.logging_level = args.logging_level or self.logging_level - self.logger = logger or log(__class__.__name__, self.logging_level) + self.logger = logger or log(self.__class__.__name__, self.logging_level) credentials = None if args.password or args.password_file: @@ -360,8 +373,8 @@ def _clear_data(self): self.tableau_server = None self.certificate = None - self.no_certcheck = None - self.no_proxy = None + self.no_certcheck = False + self.no_proxy = False self.proxy = None self.timeout = None @@ -440,13 +453,13 @@ def _save_session_to_json(self): except Exception as e: self._wipe_bad_json(e, "Failed to save session file") - def _save_file(self, data): + def _save_file(self, data: Dict[str, Any]) -> None: file_path = self._get_file_path() with open(str(file_path), "w") as f: json.dump(data, f) - def _serialize_for_save(self): - data = {"tableau_auth": []} + def _serialize_for_save(self) -> Dict[str, Any]: + data: Dict[str, Any] = {"tableau_auth": []} data["tableau_auth"].append( { "auth_token": self.auth_token, From c8901ae2217f90483608e789496e69ee894aa29d Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Wed, 13 Aug 2025 23:51:15 -0700 Subject: [PATCH 07/28] typing: parser tests --- tests/parsers/common_setup.py | 44 ++++++++++++------- tests/parsers/test_login_parser.py | 4 +- tests/parsers/test_logout_parser.py | 2 +- tests/parsers/test_parser_add_user.py | 6 +-- tests/parsers/test_parser_create_extracts.py | 2 +- tests/parsers/test_parser_create_group.py | 2 +- tests/parsers/test_parser_create_project.py | 2 +- tests/parsers/test_parser_create_site.py | 2 +- .../parsers/test_parser_create_site_users.py | 2 +- tests/parsers/test_parser_create_user.py | 2 +- tests/parsers/test_parser_decrypt_extracts.py | 2 +- tests/parsers/test_parser_delete.py | 2 +- tests/parsers/test_parser_delete_extracts.py | 2 +- tests/parsers/test_parser_delete_group.py | 2 +- tests/parsers/test_parser_delete_project.py | 2 +- tests/parsers/test_parser_delete_site.py | 2 +- tests/parsers/test_parser_delete_site_user.py | 2 +- tests/parsers/test_parser_edit_site.py | 2 +- tests/parsers/test_parser_encrypt_extracts.py | 2 +- tests/parsers/test_parser_export.py | 2 +- tests/parsers/test_parser_get_url.py | 4 +- tests/parsers/test_parser_list_sites.py | 2 +- tests/parsers/test_parser_publish.py | 2 +- tests/parsers/test_parser_publish_samples.py | 2 +- .../parsers/test_parser_reencrypt_extracts.py | 2 +- tests/parsers/test_parser_refresh_extracts.py | 2 +- tests/parsers/test_parser_remove_user.py | 2 +- tests/parsers/test_parser_runschedule.py | 2 +- 28 files changed, 58 insertions(+), 48 deletions(-) diff --git a/tests/parsers/common_setup.py b/tests/parsers/common_setup.py index 619d4805..90583f18 100644 --- a/tests/parsers/common_setup.py +++ b/tests/parsers/common_setup.py @@ -1,33 +1,43 @@ +import unittest +from unittest import mock from tabcmd.execution import parent_parser from collections import namedtuple +import argparse +from typing import Optional, Type, Any +__all__ = ['ParserTest', 'initialize_test_pieces', 'mock_command_action', 'encoding'] encoding = "utf-8-sig" -def mock_command_action(): +def mock_command_action() -> None: print("a mockery!") # basically replaces tabcmd_controller:initialize_parsers -def initialize_test_pieces(commandname, command_object): +def initialize_test_pieces(commandname: str, command_object: Type[Any]) -> argparse.ArgumentParser: manager = parent_parser.ParentParser() - parser = manager.get_root_parser() - mock_command = namedtuple("TestObject", "name, run_command, description, define_args") - mock_command.name = commandname - mock_command.run_command = mock_command_action - mock_command.description = "mock help text" - mock_command.define_args = command_object.define_args + parser_under_test = manager.get_root_parser() + MockCommand = namedtuple("MockCommand", "name, run_command, description, define_args") + mock_command = MockCommand( + name=commandname, + run_command=mock_command_action, + description="mock help text", + define_args=command_object.define_args + ) manager.include(mock_command) - return parser + return parser_under_test -""" - base test cases for each parser: - has_required_arguments - (maybe) missing required arguments - has optional arguments - bad mix of optional arguments - has unknown arguments - """ +class ParserTest(unittest.TestCase): + parser_under_test: argparse.ArgumentParser + + """ + base test cases for each parser: + has_required_arguments + (maybe) missing required arguments + has optional arguments + bad mix of optional arguments + has unknown arguments + """ \ No newline at end of file diff --git a/tests/parsers/test_login_parser.py b/tests/parsers/test_login_parser.py index 2276ef45..5c7cef5d 100644 --- a/tests/parsers/test_login_parser.py +++ b/tests/parsers/test_login_parser.py @@ -4,11 +4,11 @@ from tabcmd.commands.auth.login_command import LoginCommand from .common_setup import * -commandname = "login" +commandname: str = "login" @mock.patch("sys.argv", None) -class LoginParserTest(unittest.TestCase): +class LoginParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, LoginCommand) diff --git a/tests/parsers/test_logout_parser.py b/tests/parsers/test_logout_parser.py index 5d59eab7..4c1aaee5 100644 --- a/tests/parsers/test_logout_parser.py +++ b/tests/parsers/test_logout_parser.py @@ -8,7 +8,7 @@ commandname = "logout" -class LogoutParserTest(unittest.TestCase): +class LogoutParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, LogoutCommand) diff --git a/tests/parsers/test_parser_add_user.py b/tests/parsers/test_parser_add_user.py index fe57f669..5b07b14f 100644 --- a/tests/parsers/test_parser_add_user.py +++ b/tests/parsers/test_parser_add_user.py @@ -7,7 +7,7 @@ commandname = "addusers" -class AddUsersParserTest(unittest.TestCase): +class AddUsersParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, AddUserCommand) @@ -26,8 +26,8 @@ def test_add_users_parser_users_file(self): with mock.patch("builtins.open", mock.mock_open(read_data="test")) as open_file: mock_args = [commandname, "group-name", "--users", "users.csv"] args = self.parser_under_test.parse_args(mock_args) - self.assertEqual(args.name, "group-name"), args - open_file.assert_called_with("users.csv", "r", -1, encoding, None), args + self.assertEqual(args.name, "group-name") + open_file.assert_called_with("users.csv", "r", -1, encoding, None) @mock.patch("builtins.open") def test_add_user_parser_missing_group_name(self, filereader): diff --git a/tests/parsers/test_parser_create_extracts.py b/tests/parsers/test_parser_create_extracts.py index 8a6d3f49..94ca7a7e 100644 --- a/tests/parsers/test_parser_create_extracts.py +++ b/tests/parsers/test_parser_create_extracts.py @@ -6,7 +6,7 @@ commandname = "createextracts" -class CreateExtractsParserTest(unittest.TestCase): +class CreateExtractsParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, CreateExtracts) diff --git a/tests/parsers/test_parser_create_group.py b/tests/parsers/test_parser_create_group.py index de05934d..db9500aa 100644 --- a/tests/parsers/test_parser_create_group.py +++ b/tests/parsers/test_parser_create_group.py @@ -6,7 +6,7 @@ commandname = "creategroup" -class CreateGroupParserTest(unittest.TestCase): +class CreateGroupParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, CreateGroupCommand) diff --git a/tests/parsers/test_parser_create_project.py b/tests/parsers/test_parser_create_project.py index b02319c6..1a7551f6 100644 --- a/tests/parsers/test_parser_create_project.py +++ b/tests/parsers/test_parser_create_project.py @@ -6,7 +6,7 @@ commandname = "createproject" -class CreateProjectParserTest(unittest.TestCase): +class CreateProjectParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, CreateProjectCommand) diff --git a/tests/parsers/test_parser_create_site.py b/tests/parsers/test_parser_create_site.py index ebe08813..5d4a50fb 100644 --- a/tests/parsers/test_parser_create_site.py +++ b/tests/parsers/test_parser_create_site.py @@ -6,7 +6,7 @@ commandname = "createsite" -class CreateSiteParserTest(unittest.TestCase): +class CreateSiteParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, CreateSiteCommand) diff --git a/tests/parsers/test_parser_create_site_users.py b/tests/parsers/test_parser_create_site_users.py index 3fa4dc04..93a51db2 100644 --- a/tests/parsers/test_parser_create_site_users.py +++ b/tests/parsers/test_parser_create_site_users.py @@ -7,7 +7,7 @@ commandname = "createsiteusers" -class CreateSiteUsersParserTest(unittest.TestCase): +class CreateSiteUsersParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, CreateSiteUsersCommand) diff --git a/tests/parsers/test_parser_create_user.py b/tests/parsers/test_parser_create_user.py index 7b67f24f..d4a533a1 100644 --- a/tests/parsers/test_parser_create_user.py +++ b/tests/parsers/test_parser_create_user.py @@ -7,7 +7,7 @@ commandname = "createusers" -class CreateUsersTest(unittest.TestCase): +class CreateUsersTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, CreateUsersCommand) diff --git a/tests/parsers/test_parser_decrypt_extracts.py b/tests/parsers/test_parser_decrypt_extracts.py index 1fdd5dc2..f90558c7 100644 --- a/tests/parsers/test_parser_decrypt_extracts.py +++ b/tests/parsers/test_parser_decrypt_extracts.py @@ -6,7 +6,7 @@ commandname = "decryptextracts" -class DecryptExtractsParserTest(unittest.TestCase): +class DecryptExtractsParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, DecryptExtracts) diff --git a/tests/parsers/test_parser_delete.py b/tests/parsers/test_parser_delete.py index 9d6900d0..edf93ea3 100644 --- a/tests/parsers/test_parser_delete.py +++ b/tests/parsers/test_parser_delete.py @@ -6,7 +6,7 @@ commandname = "delete" -class DeleteParserTest(unittest.TestCase): +class DeleteParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, DeleteCommand) diff --git a/tests/parsers/test_parser_delete_extracts.py b/tests/parsers/test_parser_delete_extracts.py index 1d71d187..2d12dbf8 100644 --- a/tests/parsers/test_parser_delete_extracts.py +++ b/tests/parsers/test_parser_delete_extracts.py @@ -6,7 +6,7 @@ commandname = "deleteextracts" -class DeleteExtractsParserTest(unittest.TestCase): +class DeleteExtractsParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, DeleteExtracts) diff --git a/tests/parsers/test_parser_delete_group.py b/tests/parsers/test_parser_delete_group.py index 368e3cce..865c7495 100644 --- a/tests/parsers/test_parser_delete_group.py +++ b/tests/parsers/test_parser_delete_group.py @@ -6,7 +6,7 @@ commandname = "deletegroup" -class DeleteGroupParserTestT(unittest.TestCase): +class DeleteGroupParserTestT(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, DeleteGroupCommand) diff --git a/tests/parsers/test_parser_delete_project.py b/tests/parsers/test_parser_delete_project.py index 5ecf43bc..3cb9e07c 100644 --- a/tests/parsers/test_parser_delete_project.py +++ b/tests/parsers/test_parser_delete_project.py @@ -6,7 +6,7 @@ commandname = "deleteproject" -class DeleteProjectParserTest(unittest.TestCase): +class DeleteProjectParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, DeleteProjectCommand) diff --git a/tests/parsers/test_parser_delete_site.py b/tests/parsers/test_parser_delete_site.py index 2d9562f7..26aa1f61 100644 --- a/tests/parsers/test_parser_delete_site.py +++ b/tests/parsers/test_parser_delete_site.py @@ -6,7 +6,7 @@ commandname = "deletesite" -class DeleteSiteParserTest(unittest.TestCase): +class DeleteSiteParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, DeleteSiteCommand) diff --git a/tests/parsers/test_parser_delete_site_user.py b/tests/parsers/test_parser_delete_site_user.py index c0d81b7e..e0de0c4f 100644 --- a/tests/parsers/test_parser_delete_site_user.py +++ b/tests/parsers/test_parser_delete_site_user.py @@ -7,7 +7,7 @@ commandname = "deletesiteusers" -class DeleteSiteUsersParserTest(unittest.TestCase): +class DeleteSiteUsersParserTest(ParserTest): csv = ("testname", "testpassword", "test", "test", "test", "test") @classmethod diff --git a/tests/parsers/test_parser_edit_site.py b/tests/parsers/test_parser_edit_site.py index 447deecf..98121e70 100644 --- a/tests/parsers/test_parser_edit_site.py +++ b/tests/parsers/test_parser_edit_site.py @@ -6,7 +6,7 @@ commandname = "editsites" -class EditSiteParserTest(unittest.TestCase): +class EditSiteParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, EditSiteCommand) diff --git a/tests/parsers/test_parser_encrypt_extracts.py b/tests/parsers/test_parser_encrypt_extracts.py index e87f8f2a..3162d688 100644 --- a/tests/parsers/test_parser_encrypt_extracts.py +++ b/tests/parsers/test_parser_encrypt_extracts.py @@ -6,7 +6,7 @@ commandname = "encryptextracts" -class EncryptExtractsParserTest(unittest.TestCase): +class EncryptExtractsParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, EncryptExtracts) diff --git a/tests/parsers/test_parser_export.py b/tests/parsers/test_parser_export.py index f90999a9..585f500c 100644 --- a/tests/parsers/test_parser_export.py +++ b/tests/parsers/test_parser_export.py @@ -6,7 +6,7 @@ commandname = "export" -class ExportParserTest(unittest.TestCase): +class ExportParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, ExportCommand) diff --git a/tests/parsers/test_parser_get_url.py b/tests/parsers/test_parser_get_url.py index 5467e236..3989cf7e 100644 --- a/tests/parsers/test_parser_get_url.py +++ b/tests/parsers/test_parser_get_url.py @@ -6,13 +6,13 @@ commandname = "listsites" -class GetUrlParserTest(unittest.TestCase): +class GetUrlParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, GetUrl) def test_get_url_parser_file(self): - mock_args = vars(argparse.Namespace(filename="helloworld")) + mock_args = [commandname, "--filename", "helloworld"] with self.assertRaises(SystemExit): args = self.parser_under_test.parse_args(mock_args) diff --git a/tests/parsers/test_parser_list_sites.py b/tests/parsers/test_parser_list_sites.py index 71b01234..03d86bc2 100644 --- a/tests/parsers/test_parser_list_sites.py +++ b/tests/parsers/test_parser_list_sites.py @@ -6,7 +6,7 @@ commandname = "listsites" -class ListSitesParserTest(unittest.TestCase): +class ListSitesParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, ListSiteCommand) diff --git a/tests/parsers/test_parser_publish.py b/tests/parsers/test_parser_publish.py index 65aaa20d..b26f911d 100644 --- a/tests/parsers/test_parser_publish.py +++ b/tests/parsers/test_parser_publish.py @@ -7,7 +7,7 @@ commandname = "Publish" -class PublishParserTest(unittest.TestCase): +class PublishParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, PublishCommand) diff --git a/tests/parsers/test_parser_publish_samples.py b/tests/parsers/test_parser_publish_samples.py index 43c4062a..e2b4a9c1 100644 --- a/tests/parsers/test_parser_publish_samples.py +++ b/tests/parsers/test_parser_publish_samples.py @@ -6,7 +6,7 @@ commandname = "publishsamples" -class PublishSamplesParserTest(unittest.TestCase): +class PublishSamplesParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, PublishSamplesCommand) diff --git a/tests/parsers/test_parser_reencrypt_extracts.py b/tests/parsers/test_parser_reencrypt_extracts.py index 79d82cda..01a91f6e 100644 --- a/tests/parsers/test_parser_reencrypt_extracts.py +++ b/tests/parsers/test_parser_reencrypt_extracts.py @@ -6,7 +6,7 @@ commandname = "reencryptextracts" -class ReencryptExtractsParserTest(unittest.TestCase): +class ReencryptExtractsParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, ReencryptExtracts) diff --git a/tests/parsers/test_parser_refresh_extracts.py b/tests/parsers/test_parser_refresh_extracts.py index a74ac760..2335fbfd 100644 --- a/tests/parsers/test_parser_refresh_extracts.py +++ b/tests/parsers/test_parser_refresh_extracts.py @@ -6,7 +6,7 @@ commandname = "refreshextracts" -class RefreshExtractsParserTest(unittest.TestCase): +class RefreshExtractsParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, RefreshExtracts) diff --git a/tests/parsers/test_parser_remove_user.py b/tests/parsers/test_parser_remove_user.py index 6441354a..f47666d6 100644 --- a/tests/parsers/test_parser_remove_user.py +++ b/tests/parsers/test_parser_remove_user.py @@ -7,7 +7,7 @@ commandname = "removeusers" -class RemoveUsersParserTest(unittest.TestCase): +class RemoveUsersParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, RemoveUserCommand) diff --git a/tests/parsers/test_parser_runschedule.py b/tests/parsers/test_parser_runschedule.py index 445dfb58..73a4bd90 100644 --- a/tests/parsers/test_parser_runschedule.py +++ b/tests/parsers/test_parser_runschedule.py @@ -6,7 +6,7 @@ commandname = "runschedule" -class RunScheduleParserTest(unittest.TestCase): +class RunScheduleParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, RunSchedule) From daee0901dc99591476d1763e9a90555f48cddf5c Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 14 Aug 2025 15:27:11 -0700 Subject: [PATCH 08/28] Update test_session.py --- tests/commands/test_session.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/commands/test_session.py b/tests/commands/test_session.py index 401f16ae..f6607281 100644 --- a/tests/commands/test_session.py +++ b/tests/commands/test_session.py @@ -154,9 +154,9 @@ def test__create_new_token_credential_succeeds_new_token(self, mock_pass): def test__create_new_username_credential_succeeds_new_password(self, mock_pass): test_password = "pword1" - active_session = Session() + active_session: Session = Session() active_session.username = "user" - active_session.site = "" + active_session.site_id = "" auth = active_session._create_new_credential(test_password, Session.PASSWORD_CRED_TYPE) assert auth is not None @@ -549,6 +549,7 @@ def test_connection_times_out(self): new_session.create_session(test_args, None) + class CookieTests(unittest.TestCase): def test_no_file_if_no_cookie(self): From 4cb5e6879d155e48ff98ba4028f092bf1aed5f75 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 14 Aug 2025 15:27:32 -0700 Subject: [PATCH 09/28] Update pyproject.toml --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 3591b042..ed886ef4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ required-version = 22 target-version = ['py310', 'py311'] extend-exclude = '^/bin/*' [tool.mypy] +check_untyped_defs = true disable_error_code = [ 'misc', 'import' From 3c8f7c5dbc9722820cddddaa4d5b24e7bc84f9db Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 14 Aug 2025 15:53:31 -0700 Subject: [PATCH 10/28] type fixes c:\dev\tabcmd>mypy tests tests\commands\test_projects_utils.py:22: error: Argument 1 to "_parse_project_path_to_list" of "Server" has incompatible type "None"; expected "str" [arg-type] tests\commands\test_user_utils.py:76: error: Incompatible types in assignment (expression has type "UserItem | None", variable has type "UserItem") [assignment] tests\commands\test_user_utils.py:84: error: Incompatible types in assignment (expression has type "UserItem | None", variable has type "UserItem") [assignment] tests\e2e\language_tests.py:208: error: "OnlineCommandTest" has no attribute "_get_workbook" [attr-defined] tests\e2e\tests_integration.py:68: error: Name "logger" is not defined [name-defined] tests\e2e\tests_integration.py:99: error: Name "logger" is not defined [name-defined] tests\e2e\tests_integration.py:133: error: Name "__class__" is not defined [name-defined] tests\commands\test_session.py:206: error: Argument 1 to "_allow_prompt" has incompatible type "Namespace"; expected "Session" [arg-type] tests\commands\test_session.py:211: error: Argument 1 to "_allow_prompt" has incompatible type "Namespace"; expected "Session" [arg-type] tests\commands\test_session.py:216: error: Argument 1 to "_allow_prompt" has incompatible type "Namespace"; expected "Session" [arg-type] Found 10 errors in 5 files (checked 51 source files) --- tabcmd/commands/server.py | 2 +- tests/commands/test_run_commands.py | 8 ++++---- tests/commands/test_session.py | 3 ++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tabcmd/commands/server.py b/tabcmd/commands/server.py index eb466183..5a5a2361 100644 --- a/tabcmd/commands/server.py +++ b/tabcmd/commands/server.py @@ -160,7 +160,7 @@ def get_filename_extension_if_tableau_type(logger, filename): ) @staticmethod - def get_project_by_name_and_parent_path(logger, server, project_name: str, parent_path: str) -> TSC.ProjectItem: + def get_project_by_name_and_parent_path(logger, server, project_name: Optional[str], parent_path: Optional[str]) -> TSC.ProjectItem: logger.debug(_("content_type.project") + ":{0}, {1}".format(parent_path, project_name)) if not parent_path: if not project_name: diff --git a/tests/commands/test_run_commands.py b/tests/commands/test_run_commands.py index 6960c8fe..6a8e161d 100644 --- a/tests/commands/test_run_commands.py +++ b/tests/commands/test_run_commands.py @@ -4,7 +4,7 @@ import tableauserverclient as TSC from tabcmd.commands.auth import login_command, logout_command -from tabcmd.commands.datasources_and_workbooks import delete_command, export_command, get_url_command, publish_command +from tabcmd.commands.datasources_and_workbooks import delete_command, export_command, get_url_command, publish_command, runschedule_command from tabcmd.commands.extracts import ( create_extracts_command, delete_extracts_command, @@ -281,7 +281,7 @@ def test_create_group_already_exists(self, mock_session, mock_server): mock_args.continue_if_exists = True mock_args.name = "name" - mock_server.groups.create.return_value = TSC.ServerResponseError(409, "already exists", "detail") + mock_server.groups.create.return_value = TSC.ServerResponseError("409", "already exists", "detail") create_group_command.CreateGroupCommand.run_command(mock_args) mock_session.assert_called() @@ -291,7 +291,7 @@ def test_create_project_already_exists(self, mock_session, mock_server): mock_args.project_name = "repeat" mock_args.parent_project_path = "" mock_args.description = "none" - mock_server.projects.create.return_value = TSC.ServerResponseError(409, "already exists", "detail") + mock_server.projects.create.return_value = TSC.ServerResponseError("409", "already exists", "detail") create_project_command.CreateProjectCommand.run_command(mock_args) mock_session.assert_called() @@ -303,7 +303,7 @@ def test_create_site_already_exists(self, mock_session, mock_server): mock_args.site_admin_user_management = None mock_args.user_quota = None mock_args.storage_quota = None - mock_server.sites.create.return_value = TSC.ServerResponseError(409, "already exists", "detail") + mock_server.sites.create.return_value = TSC.ServerResponseError("409", "already exists", "detail") create_site_command.CreateSiteCommand.run_command(mock_args) mock_session.assert_called() diff --git a/tests/commands/test_session.py b/tests/commands/test_session.py index f6607281..5d0a6016 100644 --- a/tests/commands/test_session.py +++ b/tests/commands/test_session.py @@ -175,7 +175,7 @@ def test__create_new_token_credential_succeeds_from_self(self, mock_pass): def test__create_new_username_credential_succeeds_from_self(self, mock_pass): active_session = Session() active_session.username = "user3" - active_session.site = "" + active_session.site_id = "" auth = active_session._create_new_credential(None, Session.PASSWORD_CRED_TYPE) assert mock_pass.has_been_called() assert auth is not None @@ -202,6 +202,7 @@ def test__create_new_username_credential_succeeds_from_args(self, mock_pass): class PromptingTests(unittest.TestCase): def test_show_prompt_if_user_didnt_say(self): test_args = Namespace(**vars(args_to_mock)) + mock_session = Session() assert Session._allow_prompt(test_args) is True, test_args def test_show_prompt_if_user_said_yes(self): From 366c39ab84f1a2618ccd11746e7f94779163a09d Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Wed, 24 Sep 2025 12:40:49 -0700 Subject: [PATCH 11/28] fix type errors in /tests output with fixes > mypy tests Success: no issues found in 51 source files --- tests/commands/test_geturl_utils.py | 2 +- tests/commands/test_projects_utils.py | 1 - tests/commands/test_session.py | 11 ++++++++--- tests/commands/test_user_utils.py | 8 +++++--- tests/e2e/language_tests.py | 6 ++++++ tests/e2e/tests_integration.py | 2 +- 6 files changed, 21 insertions(+), 9 deletions(-) diff --git a/tests/commands/test_geturl_utils.py b/tests/commands/test_geturl_utils.py index b5e8dd54..627f8ffa 100644 --- a/tests/commands/test_geturl_utils.py +++ b/tests/commands/test_geturl_utils.py @@ -178,7 +178,7 @@ def test_parse_get_url_to_view_parts_with_spaces(self): 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" + url = "views\\wb name\\view-name" with self.assertRaises(SystemExit): DatasourcesWorkbooksAndViewsUrlParser.parse_get_view_url_to_view_and_custom_view_parts(mock_logger, url) diff --git a/tests/commands/test_projects_utils.py b/tests/commands/test_projects_utils.py index 5e92a10d..ef434c5c 100644 --- a/tests/commands/test_projects_utils.py +++ b/tests/commands/test_projects_utils.py @@ -19,7 +19,6 @@ class ProjectsTest(unittest.TestCase): @staticmethod def test_parent_path_to_list(): - assert Server._parse_project_path_to_list(None) == [] assert Server._parse_project_path_to_list("") == [] assert Server._parse_project_path_to_list("parent") == ["parent"] assert Server._parse_project_path_to_list("parent/child") == ["parent", "child"] diff --git a/tests/commands/test_session.py b/tests/commands/test_session.py index 5d0a6016..f423e6c1 100644 --- a/tests/commands/test_session.py +++ b/tests/commands/test_session.py @@ -203,17 +203,22 @@ class PromptingTests(unittest.TestCase): def test_show_prompt_if_user_didnt_say(self): test_args = Namespace(**vars(args_to_mock)) mock_session = Session() - assert Session._allow_prompt(test_args) is True, test_args + mock_session._update_session_data(test_args) + assert mock_session._allow_prompt() is True, test_args def test_show_prompt_if_user_said_yes(self): test_args = Namespace(**vars(args_to_mock)) test_args.prompt = True - assert Session._allow_prompt(test_args) is True, test_args + mock_session = Session() + mock_session._update_session_data(test_args) + assert mock_session._allow_prompt() is True, test_args def test_dont_show_prompt_if_user_said_no(self): test_args = Namespace(**vars(args_to_mock)) test_args.no_prompt = True - assert Session._allow_prompt(test_args) is False, test_args + mock_session = Session() + mock_session._update_session_data(test_args) + assert mock_session._allow_prompt() is False, test_args """ diff --git a/tests/commands/test_user_utils.py b/tests/commands/test_user_utils.py index b22f7ff1..516ba3de 100644 --- a/tests/commands/test_user_utils.py +++ b/tests/commands/test_user_utils.py @@ -3,7 +3,7 @@ from tabcmd.commands.user.user_data import UserCommand, Userdata from tabcmd.execution.logger_config import log -from typing import List +from typing import List, Optional import io import tableauserverclient as TSC @@ -73,7 +73,8 @@ def test_get_user_detail_empty_line(self): def test_get_user_detail_standard(self): test_line = "username, pword, fname, license, admin, pub, email" - test_user: TSC.UserItem = UserCommand._parse_line(test_line) + test_user: Optional[TSC.UserItem] = UserCommand._parse_line(test_line) + assert test_user is not None assert test_user.name == "username", test_user.name assert test_user.fullname == "fname", test_user.fullname assert test_user.site_role == "Unlicensed", test_user.site_role @@ -81,7 +82,8 @@ def test_get_user_detail_standard(self): def test_get_user_details_only_username(self): test_line = "username" - test_user: TSC.UserItem = UserCommand._parse_line(test_line) + test_user: Optional[TSC.UserItem] = UserCommand._parse_line(test_line) + assert test_user is not None def test_populate_user_details_only_some(self): values = ["username", "", "", "creator", "admin"] diff --git a/tests/e2e/language_tests.py b/tests/e2e/language_tests.py index 6fced7a3..5a701416 100644 --- a/tests/e2e/language_tests.py +++ b/tests/e2e/language_tests.py @@ -95,6 +95,12 @@ def _get_view(self, wb_name_on_server, sheet_name): arguments = [command, server_file] _test_command(arguments) + def _get_workbook(self, wb_name_on_server): + server_file = "/workbooks/" + wb_name_on_server + command = "get" + arguments = [command, server_file] + _test_command(arguments) + def _get_datasource(self, server_file): command = "get" server_file = "/datasources/" + server_file diff --git a/tests/e2e/tests_integration.py b/tests/e2e/tests_integration.py index 935751ae..97f05ac8 100644 --- a/tests/e2e/tests_integration.py +++ b/tests/e2e/tests_integration.py @@ -14,6 +14,7 @@ fakeserver = "http://SRVR" logging.disable(logging.ERROR) +logger = log("tests_integration", "info") # these are integration tests because they don't just run a command, they call interior methods # pytest -v tests/e2e/integration_tests.py @@ -132,7 +133,6 @@ def test_read_password_file(self): # test_session.create_session(args) def test_get_project(self): - logger = log(__class__.__name__, "info") server = E2EServerTests.test_log_in() Server.get_project_by_name_and_parent_path(logger, server, "Default", None) From 0600fab9a433e9d313bd3f5df3be4ac8d931e4a3 Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 12 Feb 2026 22:58:00 -0800 Subject: [PATCH 12/28] Refactor version retrieval to use importlib.metadata --- tabcmd/version.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tabcmd/version.py b/tabcmd/version.py index f7c977ca..3aee45f1 100644 --- a/tabcmd/version.py +++ b/tabcmd/version.py @@ -4,4 +4,3 @@ version = get_version("tabcmd") except PackageNotFoundError: version = "2.0.0" - pass From 5d7247a2c49ae0f56d67f39cc74db3d0c6de976b Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 13 Apr 2026 16:42:27 -0700 Subject: [PATCH 13/28] Update tabcmd/commands/datasources_and_workbooks/export_command.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tabcmd/commands/datasources_and_workbooks/export_command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tabcmd/commands/datasources_and_workbooks/export_command.py b/tabcmd/commands/datasources_and_workbooks/export_command.py index 9881bd44..829f859c 100644 --- a/tabcmd/commands/datasources_and_workbooks/export_command.py +++ b/tabcmd/commands/datasources_and_workbooks/export_command.py @@ -98,7 +98,7 @@ def run_command(cls, args): if not view_content_url and not wb_content_url: view_example = "/workbook_name/view_name" message = "{} [{}]".format( - _("export.errors.requires_workbook_view_param").format("ExportCommand"), view_example + _("export.errors.requires_workbook_view_param").format(cls.__name__), view_example ) Errors.exit_with_error(logger, message) From ac057f3e0e7ca52311a914dd93c48379085bc4bc Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 13 Apr 2026 16:43:16 -0700 Subject: [PATCH 14/28] Update tabcmd/commands/user/add_users_command.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tabcmd/commands/user/add_users_command.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tabcmd/commands/user/add_users_command.py b/tabcmd/commands/user/add_users_command.py index 3e591087..c8fc9fb2 100644 --- a/tabcmd/commands/user/add_users_command.py +++ b/tabcmd/commands/user/add_users_command.py @@ -32,5 +32,7 @@ def run_command(cls, args): try: UserCommand.act_on_users(logger, server, "added", server.groups.add_user, args) except Exception as e: - logger.error(e.__cause__) + logger.error("Error while adding users: %s", e) + if e.__cause__ is not None: + logger.error("Caused by: %s", e.__cause__) sys.exit(1) From b2028483b1c5b3ab9385da09d97a7b18d6211fe6 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 21 Apr 2026 14:50:08 -0700 Subject: [PATCH 15/28] fix typings --- tabcmd/version.py | 3 ++- tests/commands/test_listing_commands.py | 4 +--- tests/e2e/tests_integration.py | 2 -- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/tabcmd/version.py b/tabcmd/version.py index 3aee45f1..db73efe6 100644 --- a/tabcmd/version.py +++ b/tabcmd/version.py @@ -1,5 +1,6 @@ -from importlib.metadata import version as get_version, PackageNotFoundError +from importlib.metadata import PackageNotFoundError, version as get_version +version: str = "unknown" try: version = get_version("tabcmd") except PackageNotFoundError: diff --git a/tests/commands/test_listing_commands.py b/tests/commands/test_listing_commands.py index 766e12fb..5d638cb9 100644 --- a/tests/commands/test_listing_commands.py +++ b/tests/commands/test_listing_commands.py @@ -1,15 +1,13 @@ import argparse from unittest.mock import MagicMock, patch -import io -import sys from tabcmd.commands.site.list_command import ListCommand from tabcmd.commands.site.list_sites_command import ListSiteCommand -from tabcmd.execution.localize import set_client_locale import unittest from unittest import mock +mock_args = argparse.Namespace() mock_logger = mock.MagicMock() fake_item = mock.MagicMock() diff --git a/tests/e2e/tests_integration.py b/tests/e2e/tests_integration.py index 97f05ac8..136866b3 100644 --- a/tests/e2e/tests_integration.py +++ b/tests/e2e/tests_integration.py @@ -66,7 +66,6 @@ def test_log_in(): no_cookie=False, ) test_session = Session() - logger = log(__class__.__name__, "info") server = test_session.create_session(args, logger) assert test_session.auth_token is not None assert test_session.site_id is not None @@ -97,7 +96,6 @@ def test_reuse_session(self): timeout=None, no_cookie=False, ) - logger = log(__class__.__name__, "info") test_session = Session() test_session.create_session(args, logger) assert test_session.auth_token is not None From 152cb118a52bb3370a756cc49df90e01136cc871 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Mon, 29 Jun 2026 23:15:37 -0700 Subject: [PATCH 16/28] fix: set root logger level explicitly; guard against duplicate handlers - Replace basicConfig (no-op if root already has handlers) with an explicit setLevel on the root logger - Guard RotatingFileHandler and StreamHandler creation so repeated configure_log calls don't accumulate duplicate handlers - Add tests for no-duplicate and level-set behaviour Co-Authored-By: Claude Sonnet 4.6 --- tabcmd/execution/logger_config.py | 35 +++++++++++++++++----------- tests/commands/test_logger_config.py | 32 ++++++++++++++----------- 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/tabcmd/execution/logger_config.py b/tabcmd/execution/logger_config.py index 4db6fc7f..9ce46fcf 100644 --- a/tabcmd/execution/logger_config.py +++ b/tabcmd/execution/logger_config.py @@ -49,20 +49,27 @@ def configure_log(name: str, logging_level_input: str): if logging_level is not logging.INFO: FORMATS[logging.INFO] = "%(filename)-10s: %(message)-30s" - logging.basicConfig( - level=logging_level, format=log_format, datefmt="%Y-%m" "-%d " "%H:%M:%S" - ) - file_handler = logging.handlers.RotatingFileHandler( - "tabcmd.log", maxBytes=1_000_000, backupCount=5 - ) - file_handler.setLevel(logging_level) - file_handler.setFormatter(logging.Formatter(log_format)) - logging.getLogger().addHandler(file_handler) - console = logging.StreamHandler() - console.setLevel(logging_level) - console.setFormatter(logging.Formatter(log_format)) - logging.getLogger(name).addHandler(console) - return logging.getLogger(name) + root_logger = logging.getLogger() + root_logger.setLevel(logging_level) + + # Only add handlers once to avoid duplicates on repeated configure_log calls + if not any(isinstance(h, logging.handlers.RotatingFileHandler) for h in root_logger.handlers): + file_handler = logging.handlers.RotatingFileHandler( + "tabcmd.log", maxBytes=1_000_000, backupCount=5 + ) + file_handler.setLevel(logging_level) + file_handler.setFormatter(logging.Formatter(log_format, datefmt="%Y-%m-%d %H:%M:%S")) + root_logger.addHandler(file_handler) + + named_logger = logging.getLogger(name) + if not any(isinstance(h, logging.StreamHandler) and not isinstance(h, logging.FileHandler) + for h in named_logger.handlers): + console = logging.StreamHandler() + console.setLevel(logging_level) + console.setFormatter(logging.Formatter(log_format)) + named_logger.addHandler(console) + + return named_logger def log(file_name, logging_level): diff --git a/tests/commands/test_logger_config.py b/tests/commands/test_logger_config.py index 7fa3d837..531453a3 100644 --- a/tests/commands/test_logger_config.py +++ b/tests/commands/test_logger_config.py @@ -7,32 +7,36 @@ class LoggerConfigTests(unittest.TestCase): def setUp(self): - # Remove all handlers from root logger before each test to avoid - # cross-test pollution from the cumulative basicConfig/addHandler calls. root = logging.getLogger() for handler in root.handlers[:]: handler.close() root.removeHandler(handler) def test_rotating_file_handler_present(self): - """configure_log must add a RotatingFileHandler to the root logger.""" configure_log("test_rotate", "DEBUG") root_handlers = logging.getLogger().handlers - rotating_handlers = [h for h in root_handlers if isinstance(h, logging.handlers.RotatingFileHandler)] - self.assertTrue( - len(rotating_handlers) >= 1, - "Expected at least one RotatingFileHandler on the root logger", - ) + rotating = [h for h in root_handlers if isinstance(h, logging.handlers.RotatingFileHandler)] + self.assertEqual(len(rotating), 1, "Expected exactly one RotatingFileHandler on the root logger") def test_rotating_file_handler_settings(self): - """RotatingFileHandler must use 1 MB max size and 5 backups.""" configure_log("test_rotate_settings", "DEBUG") root_handlers = logging.getLogger().handlers - rotating_handlers = [h for h in root_handlers if isinstance(h, logging.handlers.RotatingFileHandler)] - self.assertTrue(len(rotating_handlers) >= 1, "No RotatingFileHandler found") - handler = rotating_handlers[0] - self.assertEqual(handler.maxBytes, 1_000_000, "maxBytes should be 1_000_000") - self.assertEqual(handler.backupCount, 5, "backupCount should be 5") + rotating = [h for h in root_handlers if isinstance(h, logging.handlers.RotatingFileHandler)] + self.assertEqual(len(rotating), 1, "Expected exactly one RotatingFileHandler on the root logger") + handler = rotating[0] + self.assertEqual(handler.maxBytes, 1_000_000) + self.assertEqual(handler.backupCount, 5) + + def test_no_duplicate_handlers_on_repeated_calls(self): + configure_log("test_dup", "DEBUG") + configure_log("test_dup", "DEBUG") + root_handlers = logging.getLogger().handlers + rotating = [h for h in root_handlers if isinstance(h, logging.handlers.RotatingFileHandler)] + self.assertEqual(len(rotating), 1, "Repeated configure_log calls must not add duplicate handlers") + + def test_root_logger_level_set(self): + configure_log("test_level", "WARNING") + self.assertEqual(logging.getLogger().level, logging.WARNING) if __name__ == "__main__": From c7b08ae5e94a799273797bfb58b387e396bc0f26 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Mon, 29 Jun 2026 23:16:27 -0700 Subject: [PATCH 17/28] test: add error-path test for get_file_type_from_filename Add test covering the case where neither filename nor URL has a recognized extension, which should exit with an error. Co-Authored-By: Claude Sonnet 4.6 --- tests/commands/test_geturl_utils.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/commands/test_geturl_utils.py b/tests/commands/test_geturl_utils.py index 7ebf45e9..a2a36384 100644 --- a/tests/commands/test_geturl_utils.py +++ b/tests/commands/test_geturl_utils.py @@ -450,6 +450,9 @@ def test_save_to_data_file(self): class FilenameExtensionTests(unittest.TestCase): + # get_file_type_from_filename(logger, url, file_name) + # file_name extension takes precedence; falls back to url extension; exits if neither recognized + def test_filename_extension_overrides_url_extension(self): mock_logger = mock.MagicMock() result = DatasourcesWorkbooksAndViewsUrlParser.get_file_type_from_filename( @@ -470,3 +473,10 @@ def test_no_filename_falls_back_to_url_extension(self): mock_logger, "report.pdf", None ) assert result == "pdf" + + def test_no_valid_extension_anywhere_exits(self): + mock_logger = mock.MagicMock() + with self.assertRaises(SystemExit): + DatasourcesWorkbooksAndViewsUrlParser.get_file_type_from_filename( + mock_logger, "view.xyz", "output.abc" + ) From 8ab80488275a3e79a95e2b3a9af34e18e467b301 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Mon, 29 Jun 2026 23:17:29 -0700 Subject: [PATCH 18/28] fix: don't sign out existing session when only --username is given The elif branch now only fires when there is no active session. If a session already exists (self.tableau_server is set), fall through to the existing-session reuse path instead of unconditionally signing out and prompting for a password. Co-Authored-By: Claude Sonnet 4.6 --- tabcmd/commands/auth/session.py | 5 ++--- tests/commands/test_session.py | 35 ++++++++++++++++++++++++--------- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/tabcmd/commands/auth/session.py b/tabcmd/commands/auth/session.py index c5f23b2f..8a3e4224 100644 --- a/tabcmd/commands/auth/session.py +++ b/tabcmd/commands/auth/session.py @@ -308,9 +308,8 @@ def create_session(self, args, logger): elif args.token_value or args.token_file: self._end_session() credentials = self._create_new_token_credential() - elif args.username: - # username given but no password/token: prompt for password (or exit if --no-prompt) - self._end_session() + elif args.username and not self.tableau_server: + # username given but no password/token and no active session: prompt for password credentials = self._create_new_credential(None, Session.PASSWORD_CRED_TYPE) else: # no login arguments given - look for saved info # maybe we're already signed in! diff --git a/tests/commands/test_session.py b/tests/commands/test_session.py index e427f8c3..51447460 100644 --- a/tests/commands/test_session.py +++ b/tests/commands/test_session.py @@ -451,39 +451,56 @@ def test_create_session_server_url_with_extra_path_in_server_arg( def test_create_session_username_only_prompts_for_password( self, mock_tsc, mock_pass, mock_file, mock_path, mock_json ): - """When username is given but no password, should call getpass to prompt.""" + """When username given, no password, no active session: should prompt via getpass.""" name = "myuser" _set_mocks_for_json_file_exists(mock_path, mock_json, does_it_exist=False) new_session = Session() - new_session.no_prompt = False # ensure clean state regardless of json mock - new_session.tableau_server = mock_tsc() - _set_mock_signin_validation_succeeds(new_session.tableau_server, name) + new_session.no_prompt = False + new_session.tableau_server = None # no active session mock_pass.return_value = "prompted_password" + mock_tsc.return_value.auth.sign_in.return_value = mock.MagicMock() test_args = Namespace(**vars(args_to_mock)) test_args.username = name test_args.no_prompt = False - # no password, no token auth = new_session.create_session(test_args, None) assert auth is not None, auth mock_pass.assert_called() @mock.patch("tableauserverclient.Server") - def test_create_session_username_only_no_prompt_exits( + def test_create_session_username_only_reuses_existing_session( self, mock_tsc, mock_pass, mock_file, mock_path, mock_json ): - """When username is given but no password and --no-prompt is set, should exit with error.""" + """When username given with no password but an active session exists: reuse it, no prompt.""" name = "myuser" _set_mocks_for_json_file_exists(mock_path, mock_json, does_it_exist=False) new_session = Session() - new_session.no_prompt = True # ensure no_prompt is set before args override new_session.tableau_server = mock_tsc() + _set_mock_signin_validation_succeeds(new_session.tableau_server, name) + + test_args = Namespace(**vars(args_to_mock)) + test_args.username = name + test_args.no_prompt = False + + auth = new_session.create_session(test_args, None) + assert auth is not None, auth + mock_pass.assert_not_called() + + @mock.patch("tableauserverclient.Server") + def test_create_session_username_only_no_prompt_exits( + self, mock_tsc, mock_pass, mock_file, mock_path, mock_json + ): + """When username given, no password, --no-prompt set, no active session: exit with error.""" + name = "myuser" + _set_mocks_for_json_file_exists(mock_path, mock_json, does_it_exist=False) + new_session = Session() + new_session.no_prompt = True + new_session.tableau_server = None # no active session test_args = Namespace(**vars(args_to_mock)) test_args.username = name test_args.no_prompt = True - # no password, no token with self.assertRaises(SystemExit): new_session.create_session(test_args, None) From 316c23b9511c9b789105f1d9eaf94bd94f9eb0d5 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 30 Jun 2026 14:35:11 -0700 Subject: [PATCH 19/28] style: black formatting --- tabcmd/execution/logger_config.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tabcmd/execution/logger_config.py b/tabcmd/execution/logger_config.py index 9ce46fcf..e20b10a1 100644 --- a/tabcmd/execution/logger_config.py +++ b/tabcmd/execution/logger_config.py @@ -54,16 +54,15 @@ def configure_log(name: str, logging_level_input: str): # Only add handlers once to avoid duplicates on repeated configure_log calls if not any(isinstance(h, logging.handlers.RotatingFileHandler) for h in root_logger.handlers): - file_handler = logging.handlers.RotatingFileHandler( - "tabcmd.log", maxBytes=1_000_000, backupCount=5 - ) + file_handler = logging.handlers.RotatingFileHandler("tabcmd.log", maxBytes=1_000_000, backupCount=5) file_handler.setLevel(logging_level) file_handler.setFormatter(logging.Formatter(log_format, datefmt="%Y-%m-%d %H:%M:%S")) root_logger.addHandler(file_handler) named_logger = logging.getLogger(name) - if not any(isinstance(h, logging.StreamHandler) and not isinstance(h, logging.FileHandler) - for h in named_logger.handlers): + if not any( + isinstance(h, logging.StreamHandler) and not isinstance(h, logging.FileHandler) for h in named_logger.handlers + ): console = logging.StreamHandler() console.setLevel(logging_level) console.setFormatter(logging.Formatter(log_format)) From 52ff6b225b4019f0fa62aa3b575e5ea8d719111e Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 30 Jun 2026 14:35:18 -0700 Subject: [PATCH 20/28] style: black formatting --- tests/commands/test_geturl_utils.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/tests/commands/test_geturl_utils.py b/tests/commands/test_geturl_utils.py index a2a36384..23024f8a 100644 --- a/tests/commands/test_geturl_utils.py +++ b/tests/commands/test_geturl_utils.py @@ -462,21 +462,15 @@ def test_filename_extension_overrides_url_extension(self): def test_filename_without_extension_falls_back_to_url_extension(self): mock_logger = mock.MagicMock() - result = DatasourcesWorkbooksAndViewsUrlParser.get_file_type_from_filename( - mock_logger, "view.png", "output" - ) + result = DatasourcesWorkbooksAndViewsUrlParser.get_file_type_from_filename(mock_logger, "view.png", "output") assert result == "png" def test_no_filename_falls_back_to_url_extension(self): mock_logger = mock.MagicMock() - result = DatasourcesWorkbooksAndViewsUrlParser.get_file_type_from_filename( - mock_logger, "report.pdf", None - ) + result = DatasourcesWorkbooksAndViewsUrlParser.get_file_type_from_filename(mock_logger, "report.pdf", None) assert result == "pdf" def test_no_valid_extension_anywhere_exits(self): mock_logger = mock.MagicMock() with self.assertRaises(SystemExit): - DatasourcesWorkbooksAndViewsUrlParser.get_file_type_from_filename( - mock_logger, "view.xyz", "output.abc" - ) + DatasourcesWorkbooksAndViewsUrlParser.get_file_type_from_filename(mock_logger, "view.xyz", "output.abc") From 7e24c465f061ff0bb9b894a1d4f7bfaf9d9e045f Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 30 Jun 2026 14:35:23 -0700 Subject: [PATCH 21/28] style: black formatting --- tests/commands/test_session.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/commands/test_session.py b/tests/commands/test_session.py index 51447460..595b5c18 100644 --- a/tests/commands/test_session.py +++ b/tests/commands/test_session.py @@ -488,9 +488,7 @@ def test_create_session_username_only_reuses_existing_session( mock_pass.assert_not_called() @mock.patch("tableauserverclient.Server") - def test_create_session_username_only_no_prompt_exits( - self, mock_tsc, mock_pass, mock_file, mock_path, mock_json - ): + def test_create_session_username_only_no_prompt_exits(self, mock_tsc, mock_pass, mock_file, mock_path, mock_json): """When username given, no password, --no-prompt set, no active session: exit with error.""" name = "myuser" _set_mocks_for_json_file_exists(mock_path, mock_json, does_it_exist=False) @@ -507,9 +505,7 @@ def test_create_session_username_only_no_prompt_exits( mock_pass.assert_not_called() @mock.patch("tableauserverclient.Server") - def test_create_session_username_and_password_no_prompt( - self, mock_tsc, mock_pass, mock_file, mock_path, mock_json - ): + def test_create_session_username_and_password_no_prompt(self, mock_tsc, mock_pass, mock_file, mock_path, mock_json): """When both username and password are given, getpass should not be called.""" name = "myuser" _set_mocks_for_json_file_exists(mock_path, mock_json, does_it_exist=False) From 7b3d3295ed64af525b44e7ec989138b1b880daf0 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 30 Jun 2026 14:57:17 -0700 Subject: [PATCH 22/28] ci: add workflow to check i18n string keys on PRs --- .github/workflows/check-strings.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/check-strings.yml diff --git a/.github/workflows/check-strings.yml b/.github/workflows/check-strings.yml new file mode 100644 index 00000000..151fd2d4 --- /dev/null +++ b/.github/workflows/check-strings.yml @@ -0,0 +1,26 @@ +name: Check i18n strings + +on: + workflow_dispatch: + pull_request: + branches: + - development + - main + +permissions: + contents: read + +jobs: + check-strings: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Check for missing i18n string keys + run: python bin/i18n/check_strings.py From 2b30522c9c24d549d412c303871b139485df4e41 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 30 Jun 2026 14:58:58 -0700 Subject: [PATCH 23/28] ci: pin action versions to v6, note regex limitation --- .github/workflows/check-strings.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-strings.yml b/.github/workflows/check-strings.yml index 151fd2d4..f918dcec 100644 --- a/.github/workflows/check-strings.yml +++ b/.github/workflows/check-strings.yml @@ -15,12 +15,14 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.12' + # Note: check_strings.py uses regex matching and will not catch dynamic keys + # (f-strings, variables, concatenation). This is a best-effort check. - name: Check for missing i18n string keys run: python bin/i18n/check_strings.py From f0de0172e7f25c51a8814cbfa874847a9f190430 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Wed, 1 Jul 2026 15:10:21 -0700 Subject: [PATCH 24/28] fix: black formatting and mypy errors for check_untyped_defs - Run black across all modified files - Convert PublishCommand.run_command from @staticmethod to @classmethod - Fix exit_with_error call passing list instead of str Co-Authored-By: Claude Sonnet 4.6 --- tabcmd/commands/auth/session.py | 8 ++++---- .../datasources_and_workbooks_command.py | 3 ++- .../datasources_workbooks_views_url_parser.py | 8 ++++++-- .../commands/datasources_and_workbooks/export_command.py | 2 +- .../commands/datasources_and_workbooks/publish_command.py | 8 ++++---- tabcmd/commands/extracts/extracts.py | 4 +--- tabcmd/commands/server.py | 4 +++- tabcmd/commands/user/add_users_command.py | 1 + tabcmd/commands/user/create_site_users.py | 2 +- tabcmd/tabcmd.py | 4 +++- tests/commands/test_run_commands.py | 8 +++++++- tests/parsers/common_setup.py | 6 +++--- 12 files changed, 36 insertions(+), 22 deletions(-) diff --git a/tabcmd/commands/auth/session.py b/tabcmd/commands/auth/session.py index cce15d8f..d506c064 100644 --- a/tabcmd/commands/auth/session.py +++ b/tabcmd/commands/auth/session.py @@ -203,7 +203,7 @@ def _open_connection_with_opts(self) -> TSC.Server: def _verify_server_connection_unauthed(self): if not self.tableau_server: Errors.exit_with_error(self.logger, "No server connection available") - + assert self.tableau_server is not None # Type hint for mypy try: self.tableau_server.use_server_version() @@ -227,10 +227,10 @@ def _create_new_connection(self) -> TSC.Server: self.tableau_server = self._open_connection_with_opts() except Exception as e: Errors.exit_with_error(self.logger, "Failed to connect to server", exception=e) - + if not self.tableau_server: Errors.exit_with_error(self.logger, "Failed to create server connection") - + assert self.tableau_server is not None # Type hint for mypy return self.tableau_server @@ -272,7 +272,7 @@ def _validate_existing_signin(self): def _sign_in(self, tableau_auth) -> TSC.Server: if not self.tableau_server: Errors.exit_with_error(self.logger, "No server connection available for sign in") - + self.logger.debug(_("session.login") + (self.server_url or "")) self.logger.debug(_("listsites.output").format("", self.username or self.token_name, self.site_name)) assert self.tableau_server is not None # Type hint for mypy 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 d7628e61..43f53ebc 100644 --- a/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py +++ b/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py @@ -5,7 +5,8 @@ from tabcmd.commands.constants import Errors from tabcmd.commands.server import Server from tabcmd.execution.localize import _ -RequestOptionsType = TSC.ExcelRequestOptions | TSC.CSVRequestOptions | TSC.PDFRequestOptions | TSC.ImageRequestOptions + +RequestOptionsType = TSC.ExcelRequestOptions | TSC.CSVRequestOptions | TSC.PDFRequestOptions | TSC.ImageRequestOptions class DatasourcesAndWorkbooks(Server): 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 index 3048efad..134153f8 100644 --- a/tabcmd/commands/datasources_and_workbooks/datasources_workbooks_views_url_parser.py +++ b/tabcmd/commands/datasources_and_workbooks/datasources_workbooks_views_url_parser.py @@ -180,11 +180,15 @@ def get_url_item_and_item_type_from_view_url(logger, url, server): @staticmethod def get_content_and_server_content_type_from_url(logger, server, view_content_url, custom_view_id): - item: TSC.ViewItem | TSC.CustomViewItem = DatasourcesAndWorkbooks.get_view_by_content_url(logger, server, view_content_url) + item: TSC.ViewItem | TSC.CustomViewItem = DatasourcesAndWorkbooks.get_view_by_content_url( + logger, server, view_content_url + ) server_content_type = server.views if custom_view_id: - custom_view_item: TSC.CustomViewItem = DatasourcesAndWorkbooks.get_custom_view_by_id(logger, server, custom_view_id) + custom_view_item: TSC.CustomViewItem = DatasourcesAndWorkbooks.get_custom_view_by_id( + logger, server, custom_view_id + ) if not custom_view_item.view or custom_view_item.view.id != item.id: Errors.exit_with_error(logger, "Invalid custom view URL provided") server_content_type = server.custom_views diff --git a/tabcmd/commands/datasources_and_workbooks/export_command.py b/tabcmd/commands/datasources_and_workbooks/export_command.py index 829f859c..326fbb82 100644 --- a/tabcmd/commands/datasources_and_workbooks/export_command.py +++ b/tabcmd/commands/datasources_and_workbooks/export_command.py @@ -13,7 +13,7 @@ pagesize = TSC.PDFRequestOptions.PageType # type alias for brevity pageorientation = TSC.PDFRequestOptions.Orientation imageresolution = TSC.ImageRequestOptions.Resolution -RequestOptionsType = TSC.ExcelRequestOptions | TSC.CSVRequestOptions | TSC.PDFRequestOptions | TSC.ImageRequestOptions +RequestOptionsType = TSC.ExcelRequestOptions | TSC.CSVRequestOptions | TSC.PDFRequestOptions | TSC.ImageRequestOptions ImageResolutionStandard = "standard" diff --git a/tabcmd/commands/datasources_and_workbooks/publish_command.py b/tabcmd/commands/datasources_and_workbooks/publish_command.py index a4139eaa..8ec8871f 100644 --- a/tabcmd/commands/datasources_and_workbooks/publish_command.py +++ b/tabcmd/commands/datasources_and_workbooks/publish_command.py @@ -42,9 +42,9 @@ def define_args(publish_parser): set_append_replace_option(group) set_parent_project_arg(group) - @staticmethod - def run_command(args): - logger = log(__class__.__name__, args.logging_level) + @classmethod + def run_command(cls, args): + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) @@ -140,7 +140,7 @@ def get_files_to_publish(args, logger): ) relative_files = list(map(lambda file: os.path.join(args.filename, file), in_place_files)) except Exception as e: - Errors.exit_with_error(logger, message=in_place_files) + Errors.exit_with_error(logger, message=str(in_place_files)) files.update(relative_files) logger.debug(len(files)) return sorted(files) diff --git a/tabcmd/commands/extracts/extracts.py b/tabcmd/commands/extracts/extracts.py index 3d2cdedd..50729dbc 100644 --- a/tabcmd/commands/extracts/extracts.py +++ b/tabcmd/commands/extracts/extracts.py @@ -22,9 +22,7 @@ def get_wb_or_ds_for_extracts(args, logger, server): if args.workbook: workbook_item = Server.get_workbook_item(logger, server, args.workbook, container) else: - workbook_item = DatasourcesAndWorkbooks.get_wb_by_content_url( - logger, server, args.url - ) + workbook_item = DatasourcesAndWorkbooks.get_wb_by_content_url(logger, server, args.url) logger.info(_("export.status").format(workbook_item.name)) return workbook_item diff --git a/tabcmd/commands/server.py b/tabcmd/commands/server.py index 5a5a2361..76aba2f4 100644 --- a/tabcmd/commands/server.py +++ b/tabcmd/commands/server.py @@ -160,7 +160,9 @@ def get_filename_extension_if_tableau_type(logger, filename): ) @staticmethod - def get_project_by_name_and_parent_path(logger, server, project_name: Optional[str], parent_path: Optional[str]) -> TSC.ProjectItem: + def get_project_by_name_and_parent_path( + logger, server, project_name: Optional[str], parent_path: Optional[str] + ) -> TSC.ProjectItem: logger.debug(_("content_type.project") + ":{0}, {1}".format(parent_path, project_name)) if not parent_path: if not project_name: diff --git a/tabcmd/commands/user/add_users_command.py b/tabcmd/commands/user/add_users_command.py index c8fc9fb2..ce6d5643 100644 --- a/tabcmd/commands/user/add_users_command.py +++ b/tabcmd/commands/user/add_users_command.py @@ -5,6 +5,7 @@ from .user_data import UserCommand import sys + class AddUserCommand(UserCommand): """ Command to Adds users to a specified group diff --git a/tabcmd/commands/user/create_site_users.py b/tabcmd/commands/user/create_site_users.py index d9229b72..c63436b0 100644 --- a/tabcmd/commands/user/create_site_users.py +++ b/tabcmd/commands/user/create_site_users.py @@ -62,7 +62,7 @@ def run_command(cls, args): logger.debug(type(e)) number_of_errors += 1 logger.debug(number_of_errors) - error_list.append(e.__class__.__name__) # + ": " + e.__cause__ or "Unknown") + error_list.append(e.__class__.__name__) # + ": " + e.__cause__ or "Unknown") logger.debug(error_list) logger.info(_("session.monitorjob.percent_complete").format(100)) logger.info(_("importcsvsummary.line.processed").format(number_of_users_listed)) diff --git a/tabcmd/tabcmd.py b/tabcmd/tabcmd.py index 44904041..c62dfb09 100644 --- a/tabcmd/tabcmd.py +++ b/tabcmd/tabcmd.py @@ -17,7 +17,9 @@ def main(): print("Keyboard Interrupt: exiting") sys.exit(1) except Exception as e: - traceback_info = f"at line {e.__traceback__.tb_lineno} of {__file__}" if e.__traceback__ else "traceback unavailable" + traceback_info = ( + f"at line {e.__traceback__.tb_lineno} of {__file__}" if e.__traceback__ else "traceback unavailable" + ) sys.stderr.writelines( [ "ERROR\n", diff --git a/tests/commands/test_run_commands.py b/tests/commands/test_run_commands.py index 6a8e161d..f1777d13 100644 --- a/tests/commands/test_run_commands.py +++ b/tests/commands/test_run_commands.py @@ -4,7 +4,13 @@ import tableauserverclient as TSC from tabcmd.commands.auth import login_command, logout_command -from tabcmd.commands.datasources_and_workbooks import delete_command, export_command, get_url_command, publish_command, runschedule_command +from tabcmd.commands.datasources_and_workbooks import ( + delete_command, + export_command, + get_url_command, + publish_command, + runschedule_command, +) from tabcmd.commands.extracts import ( create_extracts_command, delete_extracts_command, diff --git a/tests/parsers/common_setup.py b/tests/parsers/common_setup.py index 90583f18..5ad87dca 100644 --- a/tests/parsers/common_setup.py +++ b/tests/parsers/common_setup.py @@ -5,7 +5,7 @@ import argparse from typing import Optional, Type, Any -__all__ = ['ParserTest', 'initialize_test_pieces', 'mock_command_action', 'encoding'] +__all__ = ["ParserTest", "initialize_test_pieces", "mock_command_action", "encoding"] encoding = "utf-8-sig" @@ -23,7 +23,7 @@ def initialize_test_pieces(commandname: str, command_object: Type[Any]) -> argpa name=commandname, run_command=mock_command_action, description="mock help text", - define_args=command_object.define_args + define_args=command_object.define_args, ) manager.include(mock_command) @@ -40,4 +40,4 @@ class ParserTest(unittest.TestCase): has optional arguments bad mix of optional arguments has unknown arguments - """ \ No newline at end of file + """ From 001c045b1251b54c93086c0c33229aeda6c4cdf3 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Wed, 1 Jul 2026 15:13:08 -0700 Subject: [PATCH 25/28] fix: cast _ProjectItem stubs to TSC.ProjectItem in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Satisfies mypy check_untyped_defs — local stub class doesn't structurally match the expected ProjectItem type, cast() avoids changing production code. Co-Authored-By: Claude Sonnet 4.6 --- tests/commands/test_server_item_selection.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/commands/test_server_item_selection.py b/tests/commands/test_server_item_selection.py index c9d76df8..20c5a8c4 100644 --- a/tests/commands/test_server_item_selection.py +++ b/tests/commands/test_server_item_selection.py @@ -1,3 +1,5 @@ +from typing import cast + import pytest import tableauserverclient as TSC @@ -48,7 +50,7 @@ def test_filters_datasources_by_exact_project_id_when_container_provided(): ] endpoint = _DatasourcesEndpoint(items) - results = Server.get_items_by_name(logger, endpoint, "Sales", container) + results = Server.get_items_by_name(logger, endpoint, "Sales", cast(TSC.ProjectItem, container)) assert len(results) == 1 assert results[0].project_id == "proj-A" @@ -64,7 +66,7 @@ def test_raises_not_found_when_no_items_match_container_after_disambiguation(): endpoint = _DatasourcesEndpoint(items) with pytest.raises(TSC.ServerResponseError): - Server.get_items_by_name(logger, endpoint, "Sales", container) + Server.get_items_by_name(logger, endpoint, "Sales", cast(TSC.ProjectItem, container)) def test_nested_projects_same_leaf_name_returns_correct_datasource_per_container(): @@ -84,11 +86,11 @@ def test_nested_projects_same_leaf_name_returns_correct_datasource_per_container endpoint = _DatasourcesEndpoint(items) # Each lookup should return exactly one item from the target project id - res_a = Server.get_items_by_name(logger, endpoint, "my-datasource", cats_under_project_a) + res_a = Server.get_items_by_name(logger, endpoint, "my-datasource", cast(TSC.ProjectItem, cats_under_project_a)) assert len(res_a) == 1 and res_a[0].project_id == "cats-A" - res_b = Server.get_items_by_name(logger, endpoint, "my-datasource", cats_under_project_b) + res_b = Server.get_items_by_name(logger, endpoint, "my-datasource", cast(TSC.ProjectItem, cats_under_project_b)) assert len(res_b) == 1 and res_b[0].project_id == "cats-B" - res_root = Server.get_items_by_name(logger, endpoint, "my-datasource", cats_under_root) + res_root = Server.get_items_by_name(logger, endpoint, "my-datasource", cast(TSC.ProjectItem, cats_under_root)) assert len(res_root) == 1 and res_root[0].project_id == "cats-root" From e44757972339f634c9a0629e366cd4e20a887772 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Mon, 29 Jun 2026 15:27:01 -0700 Subject: [PATCH 26/28] chore: upgrade tableauserverclient to 0.41, add SVG format tests (#1772) Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 2 +- tests/commands/test_geturl_utils.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ed886ef4..a902ac7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "appdirs", "requests>=2.25,<3.0", "setuptools", - "tableauserverclient==0.40", + "tableauserverclient==0.41", "urllib3", ] [project.optional-dependencies] diff --git a/tests/commands/test_geturl_utils.py b/tests/commands/test_geturl_utils.py index 313c636a..60f5a868 100644 --- a/tests/commands/test_geturl_utils.py +++ b/tests/commands/test_geturl_utils.py @@ -474,3 +474,21 @@ def test_no_valid_extension_anywhere_exits(self): mock_logger = mock.MagicMock() with self.assertRaises(SystemExit): DatasourcesWorkbooksAndViewsUrlParser.get_file_type_from_filename(mock_logger, "view.xyz", "output.abc") + + +class ImageFormatTests(unittest.TestCase): + """Tests for TSC 0.41 ImageRequestOptions.Format constants and format parameter.""" + + def test_image_format_png(self): + assert TSC.ImageRequestOptions.Format.PNG == "PNG" + + def test_image_format_svg(self): + assert TSC.ImageRequestOptions.Format.SVG == "SVG" + + def test_image_request_options_format_parameter(self): + opts = TSC.ImageRequestOptions(format=TSC.ImageRequestOptions.Format.SVG) + assert opts.format == TSC.ImageRequestOptions.Format.SVG + + def test_image_request_options_default_format(self): + opts = TSC.ImageRequestOptions() + assert opts.format is None From 00c09e9d9b17430a7cce206808beaa696c5c54fa Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Wed, 1 Jul 2026 15:40:40 -0700 Subject: [PATCH 27/28] fix: set user_id in test so _validate_existing_signin passes The session reuse path checks self.user_id before calling users.get_by_id; without it, validation always returns None and the test hits the missing- arguments error path. Co-Authored-By: Claude Sonnet 4.6 --- tests/commands/test_session.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/commands/test_session.py b/tests/commands/test_session.py index 9546c918..646e2a99 100644 --- a/tests/commands/test_session.py +++ b/tests/commands/test_session.py @@ -483,6 +483,7 @@ def test_create_session_username_only_reuses_existing_session( _set_mocks_for_json_file_exists(mock_path, mock_json, does_it_exist=False) new_session = Session() new_session.tableau_server = mock_tsc() + new_session.user_id = "some-user-id" _set_mock_signin_validation_succeeds(new_session.tableau_server, name) test_args = Namespace(**vars(args_to_mock)) From ec299d48a58e196e64b74a07cc0331d43c08f724 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 1 Jul 2026 19:03:41 -0700 Subject: [PATCH 28/28] fix: show correct version in PyInstaller exe (W-22831646) (#410) * fix: show correct version in PyInstaller exe (W-22831646) importlib.metadata is unavailable inside PyInstaller bundles, causing tabcmd --version to always display the hardcoded fallback. Configure setuptools_scm to write tabcmd/_version.py at build time so PyInstaller bundles it; version.py falls back to that file instead of a hardcoded string. Co-Authored-By: Claude Sonnet 4.6 * fix: ensure _version.py is written by doit version before pyinstaller runs * test: verify version.py fallback import matches pyproject write_to path * fix: use 0.0 as last-resort version fallback (clearly wrong, but parseable) * fix: add assert narrowing to fix mypy union-attr error in test_version.py Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .gitignore | 4 ++++ dodo.py | 8 ++++++++ pyproject.toml | 1 + tabcmd/version.py | 7 ++++++- tests/commands/test_version.py | 33 +++++++++++++++++++++++++++++++++ 5 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 tests/commands/test_version.py diff --git a/.gitignore b/.gitignore index a03c2fcd..8c494c9a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ .idea *.DS_Store +# setuptools_scm generated version file (may appear in any subpackage) +tabcmd/**/_version.py +tabcmd/_version.py + # python build files build/ dist/ diff --git a/dodo.py b/dodo.py index 39aae448..85ea8b15 100644 --- a/dodo.py +++ b/dodo.py @@ -341,6 +341,14 @@ def write_for_pyinstaller(): version=numeric_version, ) + # Write tabcmd/_version.py so PyInstaller bundles the correct version string. + # setuptools_scm only writes this file during pip install/build; doit version + # may run in CI without a prior install step. + version_file = os.path.join("tabcmd", "_version.py") + with open(version_file, "w", encoding="utf-8") as f: + f.write("# generated by doit version -- do not edit\n") + f.write("version = {!r}\n".format(version)) + return { "actions": [write_for_pyinstaller], "verbosity": 2, diff --git a/pyproject.toml b/pyproject.toml index a902ac7d..0456f3c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,6 +3,7 @@ requires = ["build", "setuptools", "setuptools_scm"] build-backend = "setuptools.build_meta" [tool.setuptools_scm] local_scheme = "no-local-version" # require pypi supported versions always +write_to = "tabcmd/_version.py" [tool.setuptools] packages = ["tabcmd"] [tool.setuptools.package-data] diff --git a/tabcmd/version.py b/tabcmd/version.py index db73efe6..3b540282 100644 --- a/tabcmd/version.py +++ b/tabcmd/version.py @@ -4,4 +4,9 @@ try: version = get_version("tabcmd") except PackageNotFoundError: - version = "2.0.0" + # importlib.metadata is unavailable in PyInstaller bundles; fall back to the + # _version.py file that setuptools_scm writes at build time. + try: + from tabcmd._version import version + except ImportError: + version = "0.0" diff --git a/tests/commands/test_version.py b/tests/commands/test_version.py new file mode 100644 index 00000000..4c57ada5 --- /dev/null +++ b/tests/commands/test_version.py @@ -0,0 +1,33 @@ +import ast +import re +import unittest +from pathlib import Path + +ROOT = Path(__file__).parent.parent.parent + + +class VersionConsistencyTests(unittest.TestCase): + def test_version_py_fallback_matches_pyproject_write_to(self): + """The fallback import in version.py must match the write_to path in pyproject.toml.""" + pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + match = re.search(r'write_to\s*=\s*"([^"]+)"', pyproject) + self.assertIsNotNone(match, "write_to not found in [tool.setuptools_scm] in pyproject.toml") + assert match is not None + + write_to_path = match.group(1) # e.g. "tabcmd/_version.py" + expected_module = write_to_path.replace("/", ".").removesuffix(".py") # e.g. "tabcmd._version" + + version_py = (ROOT / "tabcmd" / "version.py").read_text(encoding="utf-8") + tree = ast.parse(version_py) + + fallback_modules = [] + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + fallback_modules.append(node.module) + + self.assertIn( + expected_module, + fallback_modules, + "version.py does not import from '{}' (the write_to path in pyproject.toml). " + "If write_to was changed, update the fallback import in tabcmd/version.py.".format(expected_module), + )