From 32d385707f3448830b74cbf0b0ee0033290a4e0a Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 21 Jul 2026 18:44:57 -0700 Subject: [PATCH 1/6] fix: correct output messages in listsites, deletegroup, export, createsiteusers, removeusers - listsites: show site content_url instead of internal UUID for SITEID - deletegroup: log group name instead of UUID in delete status message - export/save_to_file: pass filename as content name to fix double-space in "Saved to '...'" message - createsiteusers: append str(e) to error_list instead of e.__class__.__name__ so error details show readable messages - removeusers: use new tabcmd.removeusers.group string key that includes the group name in the status message Fixes #429 Co-Authored-By: Claude Sonnet 4.6 --- .../datasources_and_workbooks_command.py | 4 ++-- tabcmd/commands/group/delete_group_command.py | 2 +- tabcmd/commands/site/list_sites_command.py | 2 +- tabcmd/commands/user/create_site_users.py | 2 +- tabcmd/commands/user/remove_users_command.py | 2 +- tabcmd/locales/en/tabcmd_messages_en.properties | 1 + 6 files changed, 7 insertions(+), 6 deletions(-) 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 ae577bfa..f7fd8b3c 100644 --- a/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py +++ b/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py @@ -183,14 +183,14 @@ def save_to_data_file(logger, output, filename): logger.info(_("httputils.found_attachment").format(filename)) with open(filename, "wb") as f: f.writelines(output) - logger.info(_("export.success").format("", filename)) + logger.info(_("export.success").format(filename, filename)) @staticmethod def save_to_file(logger, output, filename): logger.info(_("httputils.found_attachment").format(filename)) with open(filename, "wb") as f: f.write(output) - logger.info(_("export.success").format("", filename)) + logger.info(_("export.success").format(filename, filename)) @staticmethod def get_custom_view_by_id(logger, server, custom_view_id) -> TSC.CustomViewItem: diff --git a/tabcmd/commands/group/delete_group_command.py b/tabcmd/commands/group/delete_group_command.py index f7c15abc..fe1ac821 100644 --- a/tabcmd/commands/group/delete_group_command.py +++ b/tabcmd/commands/group/delete_group_command.py @@ -29,7 +29,7 @@ def run_command(cls, args): try: logger.info(_("tabcmd.find.group").format(args.name)) group_id = Server.find_group(logger, server, args.name).id - logger.info(_("deletegroup.status").format(group_id)) + logger.info(_("deletegroup.status").format(args.name)) server.groups.delete(group_id) logger.info(_("common.output.succeeded")) except Exception as e: diff --git a/tabcmd/commands/site/list_sites_command.py b/tabcmd/commands/site/list_sites_command.py index 6f204483..ac80680d 100644 --- a/tabcmd/commands/site/list_sites_command.py +++ b/tabcmd/commands/site/list_sites_command.py @@ -31,7 +31,7 @@ def run_command(cls, args): sites, pagination = server.sites.get() logger.info(_("listsites.status").format(session.username)) for site in sites: - logger.info(_("listsites.output").format(" ", site.name, site.id)) + logger.info(_("listsites.output").format(" ", site.name, site.content_url)) if args.get_extract_encryption_mode: logger.info("EXTRACTENCRYPTION: {}".format(site.extract_encryption_mode)) except Exception as e: diff --git a/tabcmd/commands/user/create_site_users.py b/tabcmd/commands/user/create_site_users.py index c63436b0..ac84af87 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(str(e)) 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/remove_users_command.py b/tabcmd/commands/user/remove_users_command.py index 51005534..aaf3471f 100644 --- a/tabcmd/commands/user/remove_users_command.py +++ b/tabcmd/commands/user/remove_users_command.py @@ -27,6 +27,6 @@ def run_command(cls, args): session = Session() server = session.create_session(args, logger) - logger.info(_("tabcmd.removeusers.server").format(args.users.name, args.name)) + logger.info(_("tabcmd.removeusers.group").format(args.users.name, args.name)) UserCommand.act_on_users(logger, server, "removed", server.groups.remove_user, args) diff --git a/tabcmd/locales/en/tabcmd_messages_en.properties b/tabcmd/locales/en/tabcmd_messages_en.properties index 7eda4bff..d2ad3c30 100644 --- a/tabcmd/locales/en/tabcmd_messages_en.properties +++ b/tabcmd/locales/en/tabcmd_messages_en.properties @@ -204,6 +204,7 @@ tabcmd.publish.options.tabbed.detailed=Publish with tabbed views enabled. Each s tabcmd.refresh.options.bridge=Refresh datasource through Tableau Bridge tabcmd.removeusers.help.group_name=The group to remove users from tabcmd.removeusers.server=Removing users listed in {0} from the server... +tabcmd.removeusers.group=Removing users listed in {0} from the group ''{1}''... tabcmd.report.error.user_csv.at_char=If a user name includes an @ character that represents anything other than a domain separator, you need to refer to the symbol using the hexadecimal format: \\0x40 tabcmd.report.error.user_csv.too_many_columns=The file contains {0} columns, but there are only {1} valid columns in a user import csv file tabcmd.report.error.user.no_spaces_in_username=Username cannot contain spaces From 189980633edebf1aa6864c0a048063b1adb7c380 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 21 Jul 2026 22:15:33 -0700 Subject: [PATCH 2/6] fix: add ===== prefix to all INFO output, fix delete path, suppress debug noise - logger_config.py: add ===== prefix to INFO format so all action lines match tabcmd 1 style in one place - session.py: remove hardcoded ===== from _print_server_info lines (now added by formatter); demote "Fetched user details from server" to debug - constants.py: demote tabcmd.debug.error_message log call from info to debug so it no longer appears in normal output - delete_command.py: default content_type to "workbook" in status message to fix double-space when type is undetected - tabcmd_messages_en.properties: remove hardcoded ===== from tabcmd.listing.header (would double-prefix with formatter) Closes remaining items from #429 Co-Authored-By: Claude Sonnet 4.6 --- tabcmd/commands/auth/session.py | 12 ++++++------ tabcmd/commands/constants.py | 2 +- .../datasources_and_workbooks/delete_command.py | 2 +- tabcmd/execution/logger_config.py | 2 +- tabcmd/locales/en/tabcmd_messages_en.properties | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tabcmd/commands/auth/session.py b/tabcmd/commands/auth/session.py index 606d9bac..a3a86b9a 100644 --- a/tabcmd/commands/auth/session.py +++ b/tabcmd/commands/auth/session.py @@ -262,15 +262,15 @@ def _read_existing_state(self): self._read_from_json() def _print_server_info(self): - self.logger.info("===== Server: {}".format(self.server_url)) + self.logger.info(" Server: {}".format(self.server_url)) if self.proxy: - self.logger.info("===== Proxy: {}".format(self.proxy)) + self.logger.info(" Proxy: {}".format(self.proxy)) if self.username: - self.logger.info("===== Username: {}".format(self.username)) + self.logger.info(" Username: {}".format(self.username)) if self.certificate: - self.logger.info("===== Certificate: {}".format(self.certificate)) + self.logger.info(" Certificate: {}".format(self.certificate)) else: - self.logger.info("===== Token Name: {}".format(self.token_name)) + self.logger.info(" Token Name: {}".format(self.token_name)) site_display_name = self.site_name or "Default Site" self.logger.info(_("dataconnections.classes.tableau_server_site") + ": {}".format(site_display_name)) @@ -281,7 +281,7 @@ def _validate_existing_signin(self): 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") + self.logger.debug("Fetched user details from server") self.username = server_user return self.tableau_server diff --git a/tabcmd/commands/constants.py b/tabcmd/commands/constants.py index b728c976..102b8eef 100644 --- a/tabcmd/commands/constants.py +++ b/tabcmd/commands/constants.py @@ -65,7 +65,7 @@ def exit_with_error(logger, message: Optional[str] = None, exception: Optional[E Errors.log_stack(logger) elif exception: if message: - logger.info(_("tabcmd.debug.error_message") + message) + logger.debug(_("tabcmd.debug.error_message") + message) Errors.check_common_error_codes_and_explain(logger, exception) else: logger.info(_("tabcmd.debug.no_exception_or_message")) diff --git a/tabcmd/commands/datasources_and_workbooks/delete_command.py b/tabcmd/commands/datasources_and_workbooks/delete_command.py index 71085c74..3d372a05 100644 --- a/tabcmd/commands/datasources_and_workbooks/delete_command.py +++ b/tabcmd/commands/datasources_and_workbooks/delete_command.py @@ -47,7 +47,7 @@ def run_command(cls, args): else: Errors.exit_with_error(logger, _("tabcmd.errors.parent.not.found")) - logger.info(_("delete.status").format(content_type, item_name or args.name)) + logger.info(_("delete.status").format(content_type or "workbook", item_name or args.name)) error = None if args.workbook or not content_type: diff --git a/tabcmd/execution/logger_config.py b/tabcmd/execution/logger_config.py index 72216fa7..c9dfbfc5 100644 --- a/tabcmd/execution/logger_config.py +++ b/tabcmd/execution/logger_config.py @@ -10,7 +10,7 @@ FORMATS = { logging.ERROR: "%(asctime)s %(levelname)-5s:(%(name)-10s %(filename)-10s: %(lineno)d): %(message)-30s", logging.WARN: "%(asctime)s %(levelname)-5s: (%(name)-10s %(filename)-10s: %(lineno)d): %(message)-30s", - logging.INFO: "%(message)-30s", + logging.INFO: "===== %(message)-30s", logging.DEBUG: "%(asctime)s %(levelname)-5s: (%(name)-10s %(filename)-10s: %(lineno)d): %(message)-30s", } diff --git a/tabcmd/locales/en/tabcmd_messages_en.properties b/tabcmd/locales/en/tabcmd_messages_en.properties index d2ad3c30..947bfe6e 100644 --- a/tabcmd/locales/en/tabcmd_messages_en.properties +++ b/tabcmd/locales/en/tabcmd_messages_en.properties @@ -166,7 +166,7 @@ tabcmd.global.help.page_size=Specify the page size for query results tabcmd.global.help.skip_connection_check=Skip connection check: do not validate the connection during publishing tabcmd.howto=Run a specific command tabcmd.launching=Launching tabcmd -tabcmd.listing.header====== Listing {0} content for user {1}... +tabcmd.listing.header= Listing {0} content for user {1}... tabcmd.listing.label.id=ID: {} tabcmd.listing.label.name=\tNAME: {} tabcmd.listing.none=No content found From 494c015f171f2231c2d1ba72f32f3609efa6ceaf Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 21 Jul 2026 22:16:19 -0700 Subject: [PATCH 3/6] fix: print version/help output directly instead of via logger.info Avoids the ===== prefix being applied to the version/help display. Co-Authored-By: Claude Sonnet 4.6 --- tabcmd/execution/parent_parser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tabcmd/execution/parent_parser.py b/tabcmd/execution/parent_parser.py index 2a4183e7..ad3173f5 100644 --- a/tabcmd/execution/parent_parser.py +++ b/tabcmd/execution/parent_parser.py @@ -198,5 +198,5 @@ def __init__(self, _parser: ParentParser): def run_command(self, args): logger = log(__name__, "info") - logger.info(f"{_('tabcmd.name')} {version}\n") - logger.info(self.parser.root.format_help()) + print(f"{_('tabcmd.name')} {version}\n") + print(self.parser.root.format_help()) From 6d5ff6c339bfd2c234d2b113cb34327dae8210c1 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 21 Jul 2026 22:18:54 -0700 Subject: [PATCH 4/6] feat: add TABCMD_CLASSIC_OUTPUT env var to enable ===== prefix on output Plain output is the default. Set TABCMD_CLASSIC_OUTPUT=true (or 1/yes) to restore the ===== prefix on all INFO-level messages for backward compatibility with tabcmd 1 output style. Also moves the version banner in tabcmd_controller to print() so it is never prefixed regardless of mode. Co-Authored-By: Claude Sonnet 4.6 --- tabcmd/execution/logger_config.py | 5 ++++- tabcmd/execution/tabcmd_controller.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tabcmd/execution/logger_config.py b/tabcmd/execution/logger_config.py index c9dfbfc5..64823770 100644 --- a/tabcmd/execution/logger_config.py +++ b/tabcmd/execution/logger_config.py @@ -7,10 +7,13 @@ path = os.path.dirname(os.path.abspath(__file__)) +_CLASSIC_OUTPUT = os.environ.get("TABCMD_CLASSIC_OUTPUT", "").lower() in ("1", "true", "yes") +_INFO_FORMAT = "===== %(message)-30s" if _CLASSIC_OUTPUT else "%(message)-30s" + FORMATS = { logging.ERROR: "%(asctime)s %(levelname)-5s:(%(name)-10s %(filename)-10s: %(lineno)d): %(message)-30s", logging.WARN: "%(asctime)s %(levelname)-5s: (%(name)-10s %(filename)-10s: %(lineno)d): %(message)-30s", - logging.INFO: "===== %(message)-30s", + logging.INFO: _INFO_FORMAT, logging.DEBUG: "%(asctime)s %(levelname)-5s: (%(name)-10s %(filename)-10s: %(lineno)d): %(message)-30s", } diff --git a/tabcmd/execution/tabcmd_controller.py b/tabcmd/execution/tabcmd_controller.py index 3b544d2c..d728ddb8 100644 --- a/tabcmd/execution/tabcmd_controller.py +++ b/tabcmd/execution/tabcmd_controller.py @@ -36,7 +36,7 @@ def run(parser, user_input=None): print("logging:", namespace.logging_level) logger = log(__name__, namespace.logging_level or logging.INFO) - logger.info("Tabcmd {}".format(version)) + print("Tabcmd {}".format(version)) if hasattr(namespace, "password") or hasattr(namespace, "token_value"): # don't print whole namespace because it has secrets logger.debug(namespace.func) From 0d6dc0a619a8ed09fef908073ebc48e12c620378 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 21 Jul 2026 22:22:10 -0700 Subject: [PATCH 5/6] fix: remove spurious 'logging: INFO' printed on every command The condition compared logging_level string 'INFO' to the integer logging.INFO (20), which was always True, causing the print to fire on every invocation. Co-Authored-By: Claude Sonnet 4.6 --- tabcmd/execution/tabcmd_controller.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tabcmd/execution/tabcmd_controller.py b/tabcmd/execution/tabcmd_controller.py index d728ddb8..4a25243c 100644 --- a/tabcmd/execution/tabcmd_controller.py +++ b/tabcmd/execution/tabcmd_controller.py @@ -32,10 +32,7 @@ def run(parser, user_input=None): parser.print_help() sys.exit(0) - if hasattr(namespace, "logging_level") and namespace.logging_level != logging.INFO: - print("logging:", namespace.logging_level) - - logger = log(__name__, namespace.logging_level or logging.INFO) + logger = log(__name__, namespace.logging_level or "INFO") print("Tabcmd {}".format(version)) if hasattr(namespace, "password") or hasattr(namespace, "token_value"): # don't print whole namespace because it has secrets From ee42aa903bc72a3b6ba37c4539075381e04a62f9 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 21 Jul 2026 22:23:55 -0700 Subject: [PATCH 6/6] fix: add 'Signing in...' message to match tabcmd 1 login output session.login string already existed; add logger.info call at the start of _sign_in() to surface it as a distinct step after connecting. Co-Authored-By: Claude Sonnet 4.6 --- tabcmd/commands/auth/session.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tabcmd/commands/auth/session.py b/tabcmd/commands/auth/session.py index a3a86b9a..9965a523 100644 --- a/tabcmd/commands/auth/session.py +++ b/tabcmd/commands/auth/session.py @@ -296,6 +296,7 @@ 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.info(_("session.login")) 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